From ea3be66a11fbe74310c881b070771eb3302ac51d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:48:25 -0300 Subject: [PATCH 1/2] ci: require SHA pins and release annotations in workflow policy Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/workflow-policy.yml | 6 ++- actions/nextcloud-appstore-publish/action.yml | 10 ++--- docs/repository-governance.md | 41 +++++++++++++++++++ scripts/check_workflow_policy.py | 17 ++++++++ tests/test_workflow_policy.py | 41 +++++++++++++++++-- workflow-templates/appstore-build-publish.yml | 2 +- workflow-templates/nightly-release.yml | 8 ++-- workflow-templates/prepare-release.yml | 6 +-- 8 files changed, 113 insertions(+), 18 deletions(-) diff --git a/.github/workflows/workflow-policy.yml b/.github/workflows/workflow-policy.yml index 9087d02..a6e5b63 100644 --- a/.github/workflows/workflow-policy.yml +++ b/.github/workflows/workflow-policy.yml @@ -33,5 +33,7 @@ jobs: with: persist-credentials: false - - name: Check workflow policy - run: python3 scripts/check_workflow_policy.py + - name: Check workflow policy and regression tests + run: | + python3 -m unittest tests.test_workflow_policy -v + python3 scripts/check_workflow_policy.py diff --git a/actions/nextcloud-appstore-publish/action.yml b/actions/nextcloud-appstore-publish/action.yml index 04dd422..3f66b68 100644 --- a/actions/nextcloud-appstore-publish/action.yml +++ b/actions/nextcloud-appstore-publish/action.yml @@ -41,7 +41,7 @@ runs: using: composite steps: - name: Check actor permission - uses: LibreCodeCoop/release-tool/actions/authorization-check@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/authorization-check@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: github-token: ${{ inputs.github-token }} repository: ${{ github.repository }} @@ -79,7 +79,7 @@ runs: - name: Validate release identity id: release_identity - uses: LibreCodeCoop/release-tool/actions/release-identity@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/release-identity@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: tag: ${{ inputs.release-tag }} working-directory: ${{ inputs.app-name }} @@ -87,7 +87,7 @@ runs: - name: Inspect application build metadata id: build_metadata - uses: LibreCodeCoop/release-tool/actions/build-inspect@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/build-inspect@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: root: ${{ inputs.app-name }} @@ -264,7 +264,7 @@ runs: GITHUB_TOKEN: ${{ inputs.github-token }} - name: Validate release artifact - uses: LibreCodeCoop/release-tool/actions/artifact-validate@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/artifact-validate@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: artifact: ${{ inputs.app-name }}/build/artifacts/${{ inputs.app-name }}.tar.gz app-name: ${{ inputs.app-name }} @@ -293,7 +293,7 @@ runs: app_private_key: ${{ inputs.app-private-key }} - name: Verify App Store publication - uses: LibreCodeCoop/release-tool/actions/appstore-publication-wait@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/appstore-publication-wait@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: app-name: ${{ inputs.app-name }} version: ${{ steps.release_identity.outputs.version }} diff --git a/docs/repository-governance.md b/docs/repository-governance.md index 24de1eb..1929828 100644 --- a/docs/repository-governance.md +++ b/docs/repository-governance.md @@ -38,6 +38,47 @@ already applied on GitHub. An organization-wide rollout is not automatic: the consumer must have the matching check before protection is enabled. Other repositories and stable branches are outside this initial CI gate. +## GitHub Actions: immutable references and annotated versions + +All remote `uses:` references must use a full **40-character Git commit SHA**, +not mutable `@vN`, `@main`, or a shortened hash. For actions with tagged +releases, keep the **upstream** version next to the SHA (for example +`# v4.4.0` or `# 4.0.0`) so updates can be reviewed and detected by +Dependabot. Do not invent a version number. Local `./` actions are exempt; +in-house actions without version tags use an explicit source description, +such as `# main at abcdef1`. + +The [workflow-policy checker](../scripts/check_workflow_policy.py) tests +this convention for organization-owned workflow templates and workflows. +Other repositories must adopt equivalent CI checks (or a centrally required +workflow) and configure the `github-actions` ecosystem in Dependabot. +These local checks are **not** an organization-wide requirement by themselves. + +GitHub also provides an organization-wide Actions setting named +**Require actions to be pinned to a full-length commit SHA**. It blocks +execution of unpinned actions but does **not** verify the adjacent version +comment. At the time of the audit on 2026-10-23, LibreCodeCoop's +`sha_pinning_required` setting was **false**. Enabling it without migration +would break existing workflows referencing tags. + +Adopt the organization-wide gate only after this staged rollout: + +1. Inventory unpinned `uses:` declarations in active repositories + (including private ones), excluding relative actions; track the backlog. +2. Fix each affected workflow and its Dependabot configuration through + reviewed pull requests, validating the exact upstream tag -> commit SHA. +3. Verify tests and production/release workflows, including workflows that + only execute on push, tag, or dispatch. +4. Enable `sha_pinning_required=true` under organization **Settings > + Actions > General** once those repositories are compliant; recheck + execution and maintain a rollback procedure. +5. Keep version comments covered by CI / organization-required workflows. + The GitHub-native policy alone cannot enforce that annotation. + +Relevant GitHub guidance: +[organization Actions policy](https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization), +[secure use of Actions](https://docs.github.com/en/actions/reference/security/secure-use). + ## Contract for `ci-required` Each consuming repository must publish a real pull-request check called diff --git a/scripts/check_workflow_policy.py b/scripts/check_workflow_policy.py index 982bfe2..8365526 100644 --- a/scripts/check_workflow_policy.py +++ b/scripts/check_workflow_policy.py @@ -10,6 +10,10 @@ USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*([^@\s]+)@([^\s#]+)") FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +# Preserve the upstream release identifier for Dependabot and human review. +# Some upstream releases use only major.minor or omit the v prefix. +VERSION_COMMENT_RE = re.compile(r"^#\s*v?\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9.-]+)?\s*$") +INTERNAL_OWNERS = ("LibreCodeCoop/", "LibreSign/") def workflow_files(root: Path) -> list[Path]: @@ -42,6 +46,19 @@ def check_file(path: Path) -> list[str]: findings.append( f"{path}:{line_number}: {action} must be pinned to a full 40-character commit SHA" ) + else: + annotation = line[match.end():].strip() + if action.startswith(INTERNAL_OWNERS): + # Internal actions without tagged releases still require an + # explicit source reference, e.g. "# main at ". + if not re.fullmatch(r"#\s*\S(?:.*\S)?", annotation): + findings.append( + f"{path}:{line_number}: {action} must include a source/version comment" + ) + elif not VERSION_COMMENT_RE.fullmatch(annotation): + findings.append( + f"{path}:{line_number}: {action} must include its upstream version comment (# vX.Y.Z)" + ) if action == "actions/checkout": block = "\n".join(lines[index + 1 : index + 8]) diff --git a/tests/test_workflow_policy.py b/tests/test_workflow_policy.py index 90c8372..a9cfea4 100644 --- a/tests/test_workflow_policy.py +++ b/tests/test_workflow_policy.py @@ -23,10 +23,10 @@ def test_accepts_pinned_action_and_hardened_checkout(self) -> None: permissions: contents: read steps: - - uses: actions/checkout@0123456789012345678901234567890123456789 + - uses: actions/checkout@0123456789012345678901234567890123456789 # v4.4.0 with: persist-credentials: false - - uses: example/action@abcdefabcdefabcdefabcdefabcdefabcdefabcd + - uses: example/action@abcdefabcdefabcdefabcdefabcdefabcdefabcd # 1.2.3 """ ) self.assertEqual(check_file(path), []) @@ -41,7 +41,7 @@ def test_rejects_checkout_with_persisted_credentials(self) -> None: path = self.write( """ steps: - - uses: actions/checkout@0123456789012345678901234567890123456789 + - uses: actions/checkout@0123456789012345678901234567890123456789 # v4.4.0 """ ) findings = check_file(path) @@ -54,6 +54,41 @@ def test_rejects_write_all(self) -> None: self.assertEqual(len(findings), 1) self.assertIn("write-all", findings[0]) + def test_rejects_missing_version_comment_on_external_action(self) -> None: + path = self.write( + "steps:\n - uses: example/action@abcdefabcdefabcdefabcdefabcdefabcdefabcd\n" + ) + findings = check_file(path) + self.assertEqual(len(findings), 1) + self.assertIn("upstream version comment", findings[0]) + + def test_rejects_non_version_comment_on_external_action(self) -> None: + path = self.write( + "steps:\n - uses: example/action@abcdefabcdefabcdefabcdefabcdefabcdefabcd # latest\n" + ) + self.assertIn("upstream version comment", check_file(path)[0]) + + def test_accepts_tag_without_v_and_two_component_version(self) -> None: + path = self.write( + "steps:\n - uses: bats-core/bats-action@abcdefabcdefabcdefabcdefabcdefabcdefabcd # 4.0.0\n" + " - uses: example/action@0123456789012345678901234567890123456789 # v1.2\n" + ) + self.assertEqual(check_file(path), []) + + def test_internal_actions_need_source_comment_when_no_release(self) -> None: + path = self.write( + "steps:\n - uses: LibreCodeCoop/release-tool/actions/prepare@" + "abcdefabcdefabcdefabcdefabcdefabcdefabcd\n" + ) + self.assertIn("source/version comment", check_file(path)[0]) + + def test_internal_pinned_action_allows_descriptive_source_ref(self) -> None: + path = self.write( + "steps:\n - uses: LibreCodeCoop/.github/actions/helpers@" + "abcdefabcdefabcdefabcdefabcdefabcdefabcd # main at abcdef1\n" + ) + self.assertEqual(check_file(path), []) + def test_allows_local_action(self) -> None: path = self.write("steps:\n - uses: ./actions/sync-workflows\n") self.assertEqual(check_file(path), []) diff --git a/workflow-templates/appstore-build-publish.yml b/workflow-templates/appstore-build-publish.yml index 8fa50fd..7a42936 100644 --- a/workflow-templates/appstore-build-publish.yml +++ b/workflow-templates/appstore-build-publish.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Build and publish release - uses: LibreCodeCoop/.github/actions/nextcloud-appstore-publish@600b84354bdbb2da4fc32e9d4eecac1fcce60666 + uses: LibreCodeCoop/.github/actions/nextcloud-appstore-publish@600b84354bdbb2da4fc32e9d4eecac1fcce60666 # main at 600b843 with: app-name: ${{ github.event.repository.name }} release-tag: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.event.release.tag_name }} diff --git a/workflow-templates/nightly-release.yml b/workflow-templates/nightly-release.yml index 5fe008e..bead5b3 100644 --- a/workflow-templates/nightly-release.yml +++ b/workflow-templates/nightly-release.yml @@ -29,7 +29,7 @@ jobs: steps: - id: stable name: Resolve stable release line - uses: LibreCodeCoop/release-tool/actions/stable-select@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/stable-select@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: github-token: ${{ github.token }} @@ -62,7 +62,7 @@ jobs: - name: Inspect checked-out stable metadata id: build_metadata - uses: LibreCodeCoop/release-tool/actions/build-inspect@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/build-inspect@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: root: ${{ env.APP_NAME }} @@ -196,7 +196,7 @@ jobs: tar -zcf "${APP_NAME}.tar.gz" "${APP_NAME}" - name: Validate release artifact - uses: LibreCodeCoop/release-tool/actions/artifact-validate@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/artifact-validate@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: artifact: ${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }}.tar.gz app-name: ${{ env.APP_NAME }} @@ -222,7 +222,7 @@ jobs: - name: Build nightly change list id: release-note-changes - uses: LibreCodeCoop/release-tool/actions/release-notes@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/release-notes@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: repository: ${{ github.repository }} branch: ${{ steps.nightly.outputs.branch }} diff --git a/workflow-templates/prepare-release.yml b/workflow-templates/prepare-release.yml index b07ef93..d35aec1 100644 --- a/workflow-templates/prepare-release.yml +++ b/workflow-templates/prepare-release.yml @@ -87,7 +87,7 @@ jobs: git fetch --quiet origin "+refs/heads/main:refs/remotes/origin/main" - name: Prepare release - uses: LibreCodeCoop/release-tool/actions/prepare@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/prepare@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: branch: ${{ inputs.branch }} ref: ${{ inputs.ref }} @@ -132,7 +132,7 @@ jobs: git fetch --quiet --unshallow --prune origin "+refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}" "+refs/tags/*:refs/tags/*" - name: Finalize merged release - uses: LibreCodeCoop/release-tool/actions/post-merge@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/post-merge@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: pull-request-number: ${{ github.event.pull_request.number }} merger: ${{ github.event.pull_request.merged_by.login }} @@ -158,7 +158,7 @@ jobs: ref: ${{ github.event.release.tag_name }} - name: Verify publication - uses: LibreCodeCoop/release-tool/actions/publication@30ba5d8a33089331d829f586546d34cd45a529d7 + uses: LibreCodeCoop/release-tool/actions/publication@30ba5d8a33089331d829f586546d34cd45a529d7 # v0.11.21 with: github-release-id: ${{ github.event.release.id }} config-path: .nextcloud-release.yml From e7475747751bc0b08a5d67f424fc0f108b191374 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:50:22 -0300 Subject: [PATCH 2/2] test: accept version annotations on immutable release-tool pins Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/test_release_workflow_architecture.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_release_workflow_architecture.py b/tests/test_release_workflow_architecture.py index 265c999..1f90a3a 100644 --- a/tests/test_release_workflow_architecture.py +++ b/tests/test_release_workflow_architecture.py @@ -50,7 +50,7 @@ def test_action_pins_are_immutable(self) -> None: self.assertNotIn("RELEASE_SHA_PENDING", content) for line in content.splitlines(): if re.search(r"^\s*uses: LibreCodeCoop/release-tool/", line): - self.assertRegex(line, r"@[0-9a-f]{40}$") + self.assertRegex(line, r"@[0-9a-f]{40}\s+#\s+v?\d+\.\d+\.\d+$") if __name__ == "__main__":