Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 70 additions & 7 deletions .github/scripts/tests/update-packages.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -258,15 +259,17 @@ 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]
self.assertIs(update_checkout["with"]["persist-credentials"], False)
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)
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
45 changes: 43 additions & 2 deletions .github/workflows/update-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -91,6 +98,8 @@ on:
secrets:
PAT_WORKFLOWS:
required: false
AUTOMATION_APP_PRIVATE_KEY:
required: false

jobs:
update:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 }}
Expand Down
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
Loading