diff --git a/.github/scripts/tests/update-packages.test.py b/.github/scripts/tests/update-packages.test.py index 603012c..070815f 100644 --- a/.github/scripts/tests/update-packages.test.py +++ b/.github/scripts/tests/update-packages.test.py @@ -31,6 +31,7 @@ DIRECT_FREEZE = "Freeze direct candidate after builds" EXPORT = "Export validated candidate" APPLY = "Apply validated candidate" +AUTH = "Check publication authentication" NPM_UPDATES = ["Snapshot NPM peer pins", "Update NPM packages", "Synchronize NPM peers and install"] UPDATES = ["Update NuGet packages", *NPM_UPDATES, "Update Gradle packages", "Update Mix packages"] BUILDS = ["Build .NET", "Build NPM", "Build Gradle", "Build Mix"] @@ -258,7 +259,9 @@ class UpdatePackagesTests(unittest.TestCase): def assert_credential_boundary(self, workflow): jobs = workflow["jobs"] self.assertEqual(set(jobs), {"update", "publish"}) - self.assertNotIn("PAT_WORKFLOWS", json.dumps(jobs["update"])) + for credential in ("PAT_WORKFLOWS", "AUTOMATION_APP_PRIVATE_KEY", "CRATIS_AUTOMATION_APP_ID", "app-token"): + self.assertNotIn(credential, json.dumps(jobs["update"])) + self.assertNotIn("outputs", jobs["publish"]) for job in jobs.values(): self.assertEqual(job["runs-on"], "${{ inputs.runs-on }}") update_checkout = jobs["update"]["steps"][0] @@ -266,7 +269,7 @@ def assert_credential_boundary(self, workflow): self.assertEqual(update_checkout["with"]["token"], "${{ github.token }}") self.assertEqual(jobs["publish"]["needs"], "update") self.assertEqual(jobs["publish"]["if"], "needs.update.outputs.changed == 'true'") - checkout = jobs["publish"]["steps"][0] + checkout = next(step for step in jobs["publish"]["steps"] if step["name"] == "Checkout publication base") self.assertEqual(checkout["with"]["ref"], "${{ needs.update.outputs.base }}") for step in jobs["publish"]["steps"]: self.assert_publication_step(step) @@ -283,18 +286,20 @@ def assert_publication_step(self, step): self.assertNotRegex(step.get("run", ""), r"(?im)(?:^|[\s/;|&])(dotnet|yarn|npm|npx|gradlew?|mix|bash|sh|python3?|node|source|eval|curl|wget)\b") self.assertNotRegex(step.get("run", ""), r"(?m)^\s*(?:\./|scripts/)") if "uses" in step: - self.assertRegex(step["uses"], r"^actions/(checkout|download-artifact)@[0-9a-f]{40}$") + self.assertRegex(step["uses"], r"^actions/(checkout|download-artifact|create-github-app-token)@[0-9a-f]{40}$") def test_yaml_credential_boundary(self): self.assert_credential_boundary(yaml.safe_load(SOURCE)) def test_credential_boundary_detects_seeded_violations(self): # Plant each forbidden behavior independently: this guard must fail red. - for violation in ("secret", "persist", "toolchain", "hook", "script"): + for violation in ("secret", "app-key", "app-token", "persist", "toolchain", "hook", "script"): with self.subTest(violation=violation): workflow = yaml.safe_load(SOURCE) - if violation == "secret": - workflow["jobs"]["update"]["env"] = {"GH_TOKEN": "${{ secrets.PAT_WORKFLOWS }}"} + if violation in ("secret", "app-key", "app-token"): + credential = {"secret": "secrets.PAT_WORKFLOWS", "app-key": "secrets.AUTOMATION_APP_PRIVATE_KEY", + "app-token": "steps.app-token.outputs.token"}[violation] + workflow["jobs"]["update"]["env"] = {"GH_TOKEN": "${{ " + credential + " }}"} elif violation == "persist": workflow["jobs"]["update"]["steps"][0]["with"]["persist-credentials"] = True else: @@ -304,6 +309,63 @@ def test_credential_boundary_detects_seeded_violations(self): with self.assertRaises(AssertionError): self.assert_credential_boundary(workflow) + def test_publication_authentication_selects_app_only_for_fully_configured_pr_mode(self): + for mode in ("pull-request", "direct"): + for configured in (False, True): + with self.subTest(mode=mode, configured=configured): + self.env.update(PUBLICATION_MODE=mode, APP_ID="123" if configured else "", + HAS_APP_PRIVATE_KEY=str(configured).lower()) + self.assert_step_ok(AUTH) + expected = str(configured and mode == "pull-request").lower() + self.assertEqual((self.root / "step-output").read_text(), f"use-app={expected}\n") + self.assertNotIn("123", self.last_result.stdout + self.last_result.stderr) + + def test_partial_app_configuration_fails_in_both_modes(self): + for mode in ("pull-request", "direct"): + for app_id, has_key in (("123", "false"), ("", "true")): + with self.subTest(mode=mode, app_id=app_id, has_key=has_key): + self.env.update(PUBLICATION_MODE=mode, APP_ID=app_id, HAS_APP_PRIVATE_KEY=has_key) + self.assertEqual(self.run_step(AUTH), 1) + self.assertIn("Set both CRATIS_AUTOMATION_APP_ID and AUTOMATION_APP_PRIVATE_KEY, or neither.", + self.last_result.stdout) + self.assertEqual((self.root / "step-output").read_text(), "") + self.assert_not_published() + + def test_app_mint_scope_pin_revocation_and_credential_consumers(self): + workflow = yaml.safe_load(SOURCE) + steps = workflow["jobs"]["publish"]["steps"] + auth, mint, checkout = steps[:3] + self.assertEqual(auth["name"], AUTH) + self.assertEqual(auth["id"], "publication-auth") + self.assertEqual(auth["env"], { + "PUBLICATION_MODE": "${{ inputs.publication-mode }}", + "APP_ID": "${{ vars.CRATIS_AUTOMATION_APP_ID }}", + "HAS_APP_PRIVATE_KEY": "${{ secrets.AUTOMATION_APP_PRIVATE_KEY != '' }}", + }) + self.assertEqual(mint["id"], "app-token") + self.assertEqual(mint["if"], "steps.publication-auth.outputs.use-app == 'true'") + self.assertIn("uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0", + STEPS["Mint publication App token"]) + self.assertEqual(mint["with"], { + "app-id": "${{ vars.CRATIS_AUTOMATION_APP_ID }}", + "private-key": "${{ secrets.AUTOMATION_APP_PRIVATE_KEY }}", + "permission-contents": "write", "permission-pull-requests": "write", + }) # No skip-token-revoke: keep default post-job revocation. + token = "${{ steps.app-token.outputs.token || secrets.PAT_WORKFLOWS || github.token }}" + self.assertEqual(checkout["with"]["token"], token) + pr = next(step for step in steps if step["name"] == PUBLISH) + self.assertEqual(pr["env"]["GH_TOKEN"], token) + direct = next(step for step in steps if step["name"] == LEGACY) + self.assertNotIn("GH_TOKEN", direct.get("env", {})) # Direct push uses checkout's credentials. + for step in steps: + if step not in (auth, mint): + self.assertNotIn("AUTOMATION_APP_PRIVATE_KEY", json.dumps(step)) + if step not in (checkout, pr): + self.assertNotIn("steps.app-token.outputs.token", json.dumps(step)) + # PyYAML treats the unquoted YAML 1.1 'on' key as True. + self.assertEqual(workflow[True]["workflow_call"]["secrets"]["AUTOMATION_APP_PRIVATE_KEY"], + {"required": False}) + def test_direct_bundle_round_trip_preserves_postbuild_tree_and_message(self): self.env.update(PUBLICATION_MODE="direct", DIRTY_BUILD="1") self.assertTrue(self.pipeline(), self.last_result.stderr) @@ -684,7 +746,8 @@ def test_structure_and_default_contract(self): self.assertIn(f"default: {default}", block) self.assertIn("runs-on: ${{ inputs.runs-on }}", SOURCE) self.assertIn("token: ${{ github.token }}", STEPS["Checkout repository"]) - self.assertIn("token: ${{ secrets.PAT_WORKFLOWS || github.token }}", STEPS["Checkout publication base"]) + self.assertIn("token: ${{ steps.app-token.outputs.token || secrets.PAT_WORKFLOWS || github.token }}", + STEPS["Checkout publication base"]) self.assertIsNone(re.search(r"^\s*permissions:", SOURCE, re.M)) def test_shell_syntax(self): diff --git a/.github/workflows/update-packages.yml b/.github/workflows/update-packages.yml index 103f020..12680e6 100644 --- a/.github/workflows/update-packages.yml +++ b/.github/workflows/update-packages.yml @@ -23,6 +23,13 @@ name: Update Packages # mode; the reusable workflow cannot elevate its caller's token permissions. # PAT-based pushes are not affected by GITHUB_TOKEN permissions. See README # for PR-creation policy and downstream event/CI differences between tokens. +# +# Optional PR-only App authentication: caller variable CRATIS_AUTOMATION_APP_ID +# plus workflow_call secret AUTOMATION_APP_PRIVATE_KEY. Both must be set or both +# absent; partial configuration fails publish. The App needs Contents (read/write) +# and Pull requests (read/write), only on the caller repository, with no bypass. +# App-created PRs trigger downstream workflows. Direct mode keeps the PAT/token +# path even with the App configured. No mode enables automatic merging. env: DOTNET_VERSION: "10.0.x" @@ -91,6 +98,8 @@ on: secrets: PAT_WORKFLOWS: required: false + AUTOMATION_APP_PRIVATE_KEY: + required: false jobs: update: @@ -919,10 +928,42 @@ jobs: runs-on: ${{ inputs.runs-on }} timeout-minutes: 60 steps: + - name: Check publication authentication + id: publication-auth + shell: bash + env: + PUBLICATION_MODE: ${{ inputs.publication-mode }} + APP_ID: ${{ vars.CRATIS_AUTOMATION_APP_ID }} + HAS_APP_PRIVATE_KEY: ${{ secrets.AUTOMATION_APP_PRIVATE_KEY != '' }} + run: | + set -euo pipefail + if { [ -n "$APP_ID" ] && [ "$HAS_APP_PRIVATE_KEY" != true ]; } || + { [ -z "$APP_ID" ] && [ "$HAS_APP_PRIVATE_KEY" = true ]; }; then + echo "::error::Set both CRATIS_AUTOMATION_APP_ID and AUTOMATION_APP_PRIVATE_KEY, or neither." + exit 1 + fi + use_app=false + if [ "$PUBLICATION_MODE" = pull-request ] && [ -n "$APP_ID" ]; then + use_app=true + fi + echo "use-app=$use_app" >> "$GITHUB_OUTPUT" + + - name: Mint publication App token + id: app-token + if: steps.publication-auth.outputs.use-app == 'true' + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ vars.CRATIS_AUTOMATION_APP_ID }} + private-key: ${{ secrets.AUTOMATION_APP_PRIVATE_KEY }} + # No owner/repositories: the action then scopes the token to the current + # repository only (event payloads such as schedule omit repository.name). + permission-contents: write + permission-pull-requests: write + - name: Checkout publication base uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - token: ${{ secrets.PAT_WORKFLOWS || github.token }} + token: ${{ steps.app-token.outputs.token || secrets.PAT_WORKFLOWS || github.token }} ref: ${{ needs.update.outputs.base }} fetch-depth: 0 @@ -1015,7 +1056,7 @@ jobs: if: inputs.publication-mode == 'pull-request' shell: bash env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS || github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.PAT_WORKFLOWS || github.token }} CALLER_REPOSITORY: ${{ github.repository }} BASE_BRANCH: ${{ needs.update.outputs.base_branch }} EXPECTED_BASE: ${{ needs.update.outputs.base }} diff --git a/README.md b/README.md index 1d56f83..896032a 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,36 @@ offending path. No permissions block or repository/organization setting is changed by the reusable workflow. The built-in token must have repository read access even when publishing -uses a PAT. Provision Bash, Git and `gh` on the chosen runner in addition to the +uses a PAT or App. Provision Bash, Git and `gh` on the chosen runner in addition to the existing ecosystem prerequisites. Use isolated, ephemeral runners: splitting jobs does not make a persistent self-hosted runner safe if dependency code can leave processes or alter tools for a later job. +**Optional PR-only GitHub App authentication.** Set the caller's repository or +organization configuration variable `CRATIS_AUTOMATION_APP_ID` and pass the optional +`AUTOMATION_APP_PRIVATE_KEY` workflow secret. For example, add +`AUTOMATION_APP_PRIVATE_KEY: ${{ secrets.AUTOMATION_APP_PRIVATE_KEY }}` to the +wrapper's `secrets` mapping above. [Reusable workflows use the caller's variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables#configuration-variable-precedence), +so no additional input is needed. Both values must be available or both absent: +a partial configuration fails `publish` with a configuration error, in either mode. +With neither present, the existing `PAT_WORKFLOWS || github.token` path is unchanged. +With both present, only `publication-mode: pull-request` mints an installation token; +`direct` still uses the existing PAT/token path, never the App. +Set the variable per repository while piloting: an organization-level variable reaches +every caller, and bootstrapped wrappers do not pass the key, so their `publish` jobs +would fail until each wrapper passes it. + +Install the App on the approved caller repository with **Contents: write** and +**Pull requests: write** (Metadata: read is implicit). The mint requests only that +repository and those two write permissions; the App needs **no default-branch +bypass**, and the workflow never enables auto-merge. The private key is supplied +only to the mint action in `publish`; the token is used only by its checkout and +PR publication step, never by package tools in `update`. Default post-job token +revocation remains enabled. App-created PRs **do trigger downstream workflows**, +unlike the built-in token; caller CI and branch protection still decide mergeability. +Direct publication still requires the existing PAT actor's bypass where branch +protection blocks direct writes. Configuring the App does not grant that bypass. + - A classic PAT needs `repo`; a fine-grained PAT needs repository **Contents: write**, **Pull requests: write**, and **Metadata: read**, including access to the selected existing PR label. Repository branch/ruleset policies must allow the new automation @@ -256,7 +281,7 @@ processes or alter tools for a later job. - [Events created using `GITHUB_TOKEN`](https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow#triggering-a-workflow-from-a-workflow) generally do not trigger downstream workflows (except explicit dispatch events). A PR created successfully with that token therefore does **not** prove PR checks - will start. Use an approved PAT when ordinary push/PR/label events must trigger + will start. Use an approved PAT or the PR-only App path when ordinary push/PR/label events must trigger caller CI/release-intent gates; this workflow dispatches no compensating workflows. PAT permissions are independent of the workflow's `GITHUB_TOKEN` permission block.