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
2 changes: 1 addition & 1 deletion .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
publish:
name: Publish project release job
needs: [ plan, validate ]
if: ${{ needs.plan.outputs.publish == 'true' }}
if: ${{ needs.plan.outputs.publish == 'true' && needs.validate.result == 'success' }}
uses: ./.github/workflows/build-release-task.yml
permissions:
contents: write
Expand Down
4 changes: 3 additions & 1 deletion AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions:
```sh
# Anchor to the line start (optional list dash) so a commented-out '# package-ecosystem:' is not counted.
decl=$(gh api "repos/<owner>/<repo>/contents/.github/dependabot.yml?ref=<ground>" --jq '.content' | base64 -d | grep -oE '^[[:space:]]*-?[[:space:]]*package-ecosystem:[[:space:]]*"?[a-z-]+' | grep -oE '[a-z-]+$' | sort -u)
has() { gh api "repos/<owner>/<repo>/contents/$1?ref=<ground>" >/dev/null 2>&1; }
root_paths=$(gh api "repos/<owner>/<repo>/contents?ref=<ground>" --jq '.[].path') || exit 1
github_paths=$(gh api "repos/<owner>/<repo>/contents/.github?ref=<ground>" --jq '.[].path') || exit 1
has() { grep -Fxq "$1" <<<"$root_paths"$'\n'"$github_paths"; }
has .github/workflows && { grep -qx github-actions <<<"$decl" && echo "github-actions: present" || echo "github-actions: MISSING (workflows present)"; }
has .devcontainer && { grep -qx devcontainers <<<"$decl" && echo "devcontainers: present" || echo "devcontainers: MISSING (.devcontainer present)"; }
# then read dependabot.yml and confirm each present ecosystem has both a main and a develop target-branch entry
Expand Down
58 changes: 58 additions & 0 deletions scripts/tests/test_release_guards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Protect release and audit boundaries from fail-open regressions."""

from __future__ import annotations

import unittest
from pathlib import Path
from subprocess import run

REPO = Path(__file__).resolve().parents[2]


class ReleaseGuardCase(unittest.TestCase):
"""Publishing and audit discovery require their prerequisite checks to succeed."""

def test_publish_requires_successful_validation(self) -> None:
workflow = (REPO / ".github/workflows/publish-release.yml").read_text(encoding="utf-8")
files_spec = (REPO / "spec/files.json").read_text(encoding="utf-8")

self.assertIn(
"if: ${{ needs.plan.outputs.publish == 'true' && needs.validate.result == 'success' }}",
workflow,
)
self.assertIn("\"needs.validate.result == 'success'\"", files_spec)

def test_audit_root_probe_fails_before_local_path_checks(self) -> None:
audit = (REPO / "AUDIT.md").read_text(encoding="utf-8")
lines = audit.splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith(" root_paths="))
probe = "\n".join(line.removeprefix(" ") for line in lines[start : start + 3])
fake_api = r"""
gh() {
case "$2" in
repos/*/contents/.github\?*) printf '%s\n' .github/dependabot.yml .github/workflows ;;
repos/*/contents\?*) printf '%s\n' .devcontainer .github ;;
*) return 17 ;;
esac
}
"""

success = run(
["bash", "-c", f"{fake_api}\n{probe}\nhas .github/workflows && has .devcontainer"],
check=False,
)
failure = run(
["bash", "-c", f"gh() {{ return 17; }}\n{probe}\nexit 0"],
check=False,
)

self.assertEqual(0, success.returncode)
self.assertNotEqual(0, failure.returncode)
self.assertNotIn(
'gh api "repos/<owner>/<repo>/contents/$1?ref=<ground>" >/dev/null 2>&1',
audit,
)


if __name__ == "__main__":
unittest.main()
12 changes: 10 additions & 2 deletions spec/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2267,7 +2267,7 @@ def _selftest():
" publish:\n"
" name: Publish project release job\n"
" needs: [plan, validate]\n"
" if: ${{ needs.plan.outputs.publish == 'true' }}\n"
" if: ${{ needs.plan.outputs.publish == 'true' && needs.validate.result == 'success' }}\n"
" uses: acme/hub/.github/workflows/build-release-task.yml@" + "a" * 40 + " # 2.0.1\n"
" with:\n"
" github: true\n"
Expand All @@ -2277,7 +2277,7 @@ def _selftest():
"requireTokensInJob": {
"plan": ["publish-plan-task.yml"],
"validate": ["validate-task.yml"],
"publish": ["build-release-task.yml"],
"publish": ["build-release-task.yml", "needs.validate.result == 'success'"],
},
}
cases = [
Expand Down Expand Up @@ -2428,6 +2428,7 @@ def _selftest():
" publish:\n"
" name: Publish project release job\n"
" needs: [validate]\n"
" if: ${{ needs.validate.result == 'success' }}\n"
" uses: acme/hub/.github/workflows/build-release-task.yml@"
+ "a" * 40
+ " # 2.0.1\n",
Expand All @@ -2443,6 +2444,7 @@ def _selftest():
" publish:\n"
" name: Publish project release job\n"
" needs: [plan]\n"
" if: ${{ needs.validate.result == 'success' }}\n"
" uses: acme/hub/.github/workflows/build-release-task.yml@"
+ "a" * 40
+ " # 2.0.1\n",
Expand All @@ -2467,6 +2469,12 @@ def _selftest():
publish_contract,
1,
),
(
"publish-release.yml stub whose publish job ignores failed validation",
publish_stub.replace(" && needs.validate.result == 'success'", ""),
publish_contract,
1,
),
]
# The deploy-site.yml caller stub once deploy-site-task.yml is hub-hosted: no secrets: inherit
# (a cross-repository reusable workflow cannot use it), the one crossing secret named instead.
Expand Down
2 changes: 1 addition & 1 deletion spec/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
{ "path": "spec/secrets.json", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" },
{ "path": ".github/dependabot.yml", "appliesTo": "*" },
{ "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status", "validate"], "requiredCheckName": "Check pull request workflow status job", "requireTokensInJob": { "validate": ["validate-task.yml"] } }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" },
{ "path": ".github/workflows/publish-release.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["plan", "validate", "publish"], "requireTokensInJob": { "plan": ["publish-plan-task.yml"], "validate": ["validate-task.yml"], "publish": ["build-release-task.yml"] } }, "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["two-phase", "dispatch-only", "publish-on-merge"] },
{ "path": ".github/workflows/publish-release.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["plan", "validate", "publish"], "requireTokensInJob": { "plan": ["publish-plan-task.yml"], "validate": ["validate-task.yml"], "publish": ["build-release-task.yml", "needs.validate.result == 'success'"] } }, "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["two-phase", "dispatch-only", "publish-on-merge"] },
{ "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-bot"], "requireTokensInJob": { "merge-bot": ["merge-bot-task.yml", "CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"] } }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" },
{ "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "\n environment:", "contents: read", "DEPLOY_SSH_PRIVATE_KEY"] } }, "intentRef": "docs/reusable-workflows.md#adopting-the-type-specific-tasks", "appliesTo": ["hugo"] },
{ "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp"] },
Expand Down