Merge branch 'main' into fix/markdown-split
This commit is contained in:
@@ -4,3 +4,13 @@ paths:
|
||||
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
|
||||
# bundled workflow schema has not caught up with the platform syntax.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
.github/workflows/coverage-baseline-promotion.yml:
|
||||
ignore:
|
||||
# Serialize every acknowledgement for one Formula target without
|
||||
# allowing Actions' default single-pending replacement to orphan a run.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
.github/workflows/coverage-baseline-repair.yml:
|
||||
ignore:
|
||||
# Keep the closed-event dispatcher and its exact-SHA producer queued for
|
||||
# the same target instead of replacing either half of the repair chain.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
|
||||
+447
-4
@@ -5,12 +5,16 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# Only duplicate runs for the exact PR base/head revision cancel each other.
|
||||
# A later revision must not kill an earlier cold-cache producer, and every
|
||||
# protected-main SHA keeps an independent producer run.
|
||||
group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}-{1}-{2}', github.event.pull_request.number, github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('push-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -837,7 +841,9 @@ jobs:
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Verify test shards
|
||||
env:
|
||||
@@ -916,6 +922,283 @@ jobs:
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
# Null and non-built-in merge identities emit either the protected-main
|
||||
# push or the trusted pull_request_target closed repair. The built-in
|
||||
# Actions identity is the exceptional unsafe path, so its own token must
|
||||
# prove that main-merge-writers never lets it update main.
|
||||
- name: Verify auto-merge identity
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const eventBaseSha = context.payload.pull_request.base.sha;
|
||||
const appSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim();
|
||||
if (
|
||||
!appSlug ||
|
||||
appSlug !== appSlug.toLowerCase() ||
|
||||
appSlug === 'github-actions'
|
||||
) {
|
||||
core.setFailed(
|
||||
'Reviewer Router App slug repository variable is missing or unsafe.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const expectedAppOwner = `${appSlug}[bot]`;
|
||||
const writerRulesetName = 'main-merge-writers';
|
||||
const skipWorkflowPattern =
|
||||
/\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i;
|
||||
const {data: repository} = await github.rest.repos.get({owner, repo});
|
||||
function classifyMergeDefaults(repository) {
|
||||
if (
|
||||
repository === null ||
|
||||
typeof repository !== 'object' ||
|
||||
Array.isArray(repository)
|
||||
) {
|
||||
return 'invalid';
|
||||
}
|
||||
const hasTitle = Object.prototype.hasOwnProperty.call(
|
||||
repository,
|
||||
'merge_commit_title',
|
||||
);
|
||||
const hasMessage = Object.prototype.hasOwnProperty.call(
|
||||
repository,
|
||||
'merge_commit_message',
|
||||
);
|
||||
if (!hasTitle && !hasMessage) {
|
||||
return 'omitted';
|
||||
}
|
||||
if (!hasTitle || !hasMessage) {
|
||||
return 'invalid';
|
||||
}
|
||||
if (
|
||||
repository.merge_commit_title === 'MERGE_MESSAGE' &&
|
||||
['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message)
|
||||
) {
|
||||
return 'reviewed';
|
||||
}
|
||||
return 'invalid';
|
||||
}
|
||||
const mergeDefaultsProjection = classifyMergeDefaults(repository);
|
||||
if (mergeDefaultsProjection === 'invalid') {
|
||||
core.setFailed(
|
||||
'Repository merge-message defaults are malformed or changed from their reviewed values.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mergeDefaultsProjection === 'omitted') {
|
||||
core.info(
|
||||
'Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.',
|
||||
);
|
||||
}
|
||||
|
||||
const appliedRules = await github.paginate(
|
||||
'GET /repos/{owner}/{repo}/rules/branches/{branch}',
|
||||
{owner, repo, branch: 'main', per_page: 100},
|
||||
);
|
||||
const repositorySource = `${owner}/${repo}`.toLowerCase();
|
||||
const applicableRulesetIDs = [
|
||||
...new Set(
|
||||
appliedRules
|
||||
.filter(rule =>
|
||||
rule.ruleset_source_type === 'Repository' &&
|
||||
rule.ruleset_source?.toLowerCase() === repositorySource &&
|
||||
Number.isSafeInteger(Number(rule.ruleset_id)) &&
|
||||
Number(rule.ruleset_id) > 0,
|
||||
)
|
||||
.map(rule => Number(rule.ruleset_id)),
|
||||
),
|
||||
];
|
||||
const activeMainRulesets = [];
|
||||
for (const rulesetID of applicableRulesetIDs) {
|
||||
const {data: ruleset} = await github.request(
|
||||
'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}',
|
||||
{owner, repo, ruleset_id: rulesetID},
|
||||
);
|
||||
if (
|
||||
ruleset.enforcement !== 'active' ||
|
||||
ruleset.target !== 'branch' ||
|
||||
ruleset.source_type !== 'Repository' ||
|
||||
ruleset.source?.toLowerCase() !== repositorySource
|
||||
) {
|
||||
core.setFailed(
|
||||
`Applicable repository ruleset ${ruleset.name || rulesetID} is not an active branch ruleset owned by this repository.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
activeMainRulesets.push(ruleset);
|
||||
}
|
||||
|
||||
const writerRulesets = activeMainRulesets.filter(
|
||||
ruleset => ruleset.name === writerRulesetName,
|
||||
);
|
||||
if (writerRulesets.length !== 1) {
|
||||
core.setFailed(
|
||||
`Expected exactly one active ${writerRulesetName} ruleset on main; found ${writerRulesets.length}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const writerRuleset = writerRulesets[0];
|
||||
const writerIncludes = writerRuleset.conditions?.ref_name?.include || [];
|
||||
const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || [];
|
||||
// GitHub's read projection omits the entire parameters property
|
||||
// when this exception is disabled. Accept only that exact omission
|
||||
// or a one-field object containing exact false.
|
||||
function isStrictUpdateRule(rule) {
|
||||
if (rule?.type !== 'update') {
|
||||
return false;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
|
||||
return true;
|
||||
}
|
||||
const parameters = rule.parameters;
|
||||
if (
|
||||
parameters === null ||
|
||||
typeof parameters !== 'object' ||
|
||||
Array.isArray(parameters)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const parameterKeys = Object.keys(parameters);
|
||||
return (
|
||||
parameterKeys.length === 1 &&
|
||||
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
|
||||
parameters.update_allows_fetch_and_merge === false
|
||||
);
|
||||
}
|
||||
function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
|
||||
const restRulesetID = Number(restRuleset?.id);
|
||||
const graphRulesetID = Number(graphRuleset?.databaseId);
|
||||
const graphRules = graphRuleset?.rules;
|
||||
const graphRule = graphRules?.nodes?.[0];
|
||||
return (
|
||||
Number.isSafeInteger(restRulesetID) &&
|
||||
restRulesetID > 0 &&
|
||||
graphRulesetID === restRulesetID &&
|
||||
graphRuleset.name === restRuleset.name &&
|
||||
graphRuleset.enforcement === 'ACTIVE' &&
|
||||
graphRuleset.target === 'BRANCH' &&
|
||||
graphRules?.totalCount === 1 &&
|
||||
graphRules.nodes?.length === 1 &&
|
||||
graphRule?.type === 'UPDATE' &&
|
||||
graphRule.parameters?.__typename === 'UpdateParameters' &&
|
||||
graphRule.parameters.updateAllowsFetchAndMerge === false
|
||||
);
|
||||
}
|
||||
if (
|
||||
writerIncludes.length !== 1 ||
|
||||
writerIncludes[0] !== 'refs/heads/main' ||
|
||||
writerExcludes.length !== 0 ||
|
||||
typeof writerRuleset.node_id !== 'string' ||
|
||||
!writerRuleset.node_id ||
|
||||
writerRuleset.rules?.length !== 1 ||
|
||||
!isStrictUpdateRule(writerRuleset.rules[0]) ||
|
||||
writerRuleset.current_user_can_bypass !== 'never'
|
||||
) {
|
||||
core.setFailed(
|
||||
`${writerRulesetName} must target only refs/heads/main, contain only the strict update rule, and deny this built-in Actions identity any bypass.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const {node: graphWriterRuleset} = await github.graphql(
|
||||
`query ReviewerRouterWriterRule($rulesetID: ID!) {
|
||||
node(id: $rulesetID) {
|
||||
... on RepositoryRuleset {
|
||||
databaseId
|
||||
name
|
||||
enforcement
|
||||
target
|
||||
rules(first: 2) {
|
||||
totalCount
|
||||
nodes {
|
||||
type
|
||||
parameters {
|
||||
__typename
|
||||
... on UpdateParameters {
|
||||
updateAllowsFetchAndMerge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{rulesetID: writerRuleset.node_id},
|
||||
);
|
||||
if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) {
|
||||
core.setFailed(
|
||||
`${writerRulesetName} must expose one strict UPDATE rule with updateAllowsFetchAndMerge=false through GraphQL.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAttempts = 6;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (
|
||||
currentPull.head.sha !== eventHeadSha ||
|
||||
currentPull.base.sha !== eventBaseSha ||
|
||||
currentPull.state !== 'open' ||
|
||||
currentPull.draft ||
|
||||
currentPull.base.ref !== 'main'
|
||||
) {
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} state or revision changed before the Test aggregate verified auto-merge identity.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const mergeTexts = [
|
||||
currentPull.title,
|
||||
currentPull.auto_merge?.commit_title,
|
||||
currentPull.auto_merge?.commit_message,
|
||||
].filter(value => typeof value === 'string');
|
||||
if (mergeTexts.some(value => skipWorkflowPattern.test(value))) {
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!currentPull.auto_merge) {
|
||||
core.info(
|
||||
`PR #${pullNumber} has no auto-merge request; protected-main push or closed-event repair remains authoritative.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const enabledBy = currentPull.auto_merge.enabled_by?.login?.toLowerCase();
|
||||
const safeCommitHeadline = `Merge pull request #${pullNumber}`;
|
||||
const safeCommitBody =
|
||||
`Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`;
|
||||
if (
|
||||
enabledBy === expectedAppOwner &&
|
||||
currentPull.auto_merge.commit_title === safeCommitHeadline &&
|
||||
currentPull.auto_merge.commit_message === safeCommitBody
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} auto-merge is owned by the reviewed ${expectedAppOwner} identity with fixed metadata.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
core.info(
|
||||
`PR #${pullNumber} auto-merge owner or metadata is not the reviewed App value; waiting for Reviewer Router takeover (${attempt}/${maxAttempts}).`,
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} auto-merge must be null or owned by ${expectedAppOwner} with the reviewed fixed metadata.`,
|
||||
);
|
||||
}
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
needs: lint
|
||||
@@ -1246,7 +1529,7 @@ jobs:
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -1381,6 +1664,143 @@ jobs:
|
||||
path: coverage-base.txt
|
||||
retention-days: 1
|
||||
|
||||
# Documentation and release-seal pushes do not change executable coverage,
|
||||
# but their new main SHA is still a future PR merge base. Promote only an
|
||||
# exact predecessor cache after independently proving the whole push changed
|
||||
# metadata paths; fall back to a full authoritative profile on a cold chain.
|
||||
coverage-main-metadata:
|
||||
name: Coverage (main metadata cache)
|
||||
needs: lint
|
||||
if: ${{ github.event_name == 'push' && (needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out exact metadata-only main revision
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go-metadata
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify metadata-only main successor
|
||||
shell: bash
|
||||
env:
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
PUSH_AFTER_SHA: ${{ github.event.after }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
full_commit='^[0-9a-f]{40}$'
|
||||
[[ "$PUSH_BEFORE_SHA" =~ $full_commit ]]
|
||||
[[ "$PUSH_AFTER_SHA" =~ $full_commit ]]
|
||||
test "$PUSH_BEFORE_SHA" != 0000000000000000000000000000000000000000
|
||||
test "$PUSH_AFTER_SHA" = "$GITHUB_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
git rev-parse --verify "${PUSH_BEFORE_SHA}^{commit}" >/dev/null
|
||||
git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
|
||||
changed_count=0
|
||||
while IFS= read -r -d '' path; do
|
||||
changed_count=$((changed_count + 1))
|
||||
case "$path" in
|
||||
CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|CODE_OF_CONDUCT.md|LICENSE|NOTICE|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|docs/*)
|
||||
;;
|
||||
.changes/*)
|
||||
if [[ "$path" =~ ^\.changes/[a-z0-9][a-z0-9._-]*\.md$ ]] ||
|
||||
[[ "$path" =~ ^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "Refusing coverage-cache promotion for unreviewed change-fragment path: $path" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Refusing coverage-cache promotion for executable path: $path" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done < <(git diff --name-only --no-renames -z "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA")
|
||||
test "$changed_count" -gt 0
|
||||
echo "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore existing current-SHA coverage profile
|
||||
id: metadata-current-cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
- name: Validate existing current-SHA coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Restore exact predecessor coverage profile
|
||||
id: metadata-source-cache
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
- name: Validate promoted predecessor coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold metadata baseline
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute cold metadata baseline
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save metadata main SHA coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
# actions/cache/save reports upload failures as warnings. Convert an
|
||||
# absent exact target key into a hard producer failure.
|
||||
- name: Verify metadata main SHA coverage cache exists
|
||||
id: metadata-target-cache-verification
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact metadata main SHA coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
needs:
|
||||
@@ -1389,6 +1809,7 @@ jobs:
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-main-metadata
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
@@ -1405,6 +1826,7 @@ jobs:
|
||||
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }}
|
||||
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
run: |
|
||||
@@ -1413,10 +1835,14 @@ jobs:
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
main_metadata_expected=skipped
|
||||
native_expected=skipped
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
if [ "$GITHUB_EVENT_NAME" = push ]; then
|
||||
main_metadata_expected=success
|
||||
fi
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
current_expected=skipped
|
||||
current_full_expected=success
|
||||
@@ -1432,7 +1858,8 @@ jobs:
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected" \
|
||||
"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected"
|
||||
do
|
||||
name="${profile%%:*}"
|
||||
remainder="${profile#*:}"
|
||||
@@ -1572,6 +1999,22 @@ jobs:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Verify push coverage cache exists
|
||||
id: push-cache-verification
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact push coverage cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
name: Coverage Baseline Promotion
|
||||
|
||||
run-name: Promote coverage baseline for ${{ github.event.client_payload.target_sha }}
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [coverage-baseline-promote]
|
||||
|
||||
# repository_dispatch loads this workflow from the protected default branch.
|
||||
# The requested target is treated as untrusted input until the validation step
|
||||
# proves it is an exact Formula-only successor already contained in main.
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
if: github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Validate Formula-only main target
|
||||
id: validate-target
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const targetSha = context.payload.client_payload?.target_sha;
|
||||
const sourceRunId = context.payload.client_payload?.source_run_id;
|
||||
const checkRunId = Number(context.payload.client_payload?.check_run_id);
|
||||
if (!/^[0-9a-f]{40}$/.test(targetSha || '')) {
|
||||
throw new Error('coverage-baseline-promote requires one full target_sha');
|
||||
}
|
||||
if (!/^[1-9][0-9]*$/.test(sourceRunId || '')) {
|
||||
throw new Error('coverage-baseline-promote requires one source_run_id');
|
||||
}
|
||||
if (!Number.isSafeInteger(checkRunId) || checkRunId <= 0) {
|
||||
throw new Error('coverage-baseline-promote requires one safe check_run_id');
|
||||
}
|
||||
|
||||
// Bind the finalizer before any target or cache validation. A
|
||||
// later failure must complete the release-created acknowledgement
|
||||
// instead of leaving Release to poll a permanently queued check.
|
||||
const promotionExternalId = `release-${sourceRunId}-${targetSha}`;
|
||||
const {data: promotionCheck} = await github.rest.checks.get({
|
||||
owner,
|
||||
repo,
|
||||
check_run_id: checkRunId,
|
||||
});
|
||||
if (
|
||||
promotionCheck.id !== checkRunId ||
|
||||
promotionCheck.head_sha !== targetSha ||
|
||||
promotionCheck.name !== 'Coverage Baseline Cache' ||
|
||||
promotionCheck.external_id !== promotionExternalId ||
|
||||
promotionCheck.app?.slug !== 'github-actions' ||
|
||||
promotionCheck.status !== 'queued' ||
|
||||
promotionCheck.conclusion !== null
|
||||
) {
|
||||
throw new Error('coverage baseline acknowledgement has an invalid identity');
|
||||
}
|
||||
core.setOutput('target_sha', targetSha);
|
||||
core.setOutput('check_run_id', String(checkRunId));
|
||||
core.setOutput('check_external_id', promotionExternalId);
|
||||
|
||||
const {data: targetCommit} = await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: targetSha,
|
||||
per_page: 100,
|
||||
});
|
||||
const files = targetCommit.files || [];
|
||||
const message = targetCommit.commit.message;
|
||||
const formulaPath = files[0]?.filename;
|
||||
const stableFormula =
|
||||
formulaPath === 'Formula/dingtalk-workspace-cli.rb' &&
|
||||
/^chore: update formula for v[0-9]+\.[0-9]+\.[0-9]+ \[skip ci\]$/.test(message);
|
||||
const betaFormula =
|
||||
formulaPath === 'Formula/dingtalk-workspace-cli-beta.rb' &&
|
||||
/^chore: update beta formula for v[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]* \[skip ci\]$/.test(message);
|
||||
if (
|
||||
targetCommit.sha !== targetSha ||
|
||||
targetCommit.parents.length !== 1 ||
|
||||
targetCommit.author?.login !== 'github-actions[bot]' ||
|
||||
targetCommit.committer?.login !== 'github-actions[bot]' ||
|
||||
files.length !== 1 ||
|
||||
!['added', 'modified'].includes(files[0].status) ||
|
||||
(!stableFormula && !betaFormula)
|
||||
) {
|
||||
throw new Error(
|
||||
`${targetSha} is not an exact release-produced Formula-only commit`,
|
||||
);
|
||||
}
|
||||
|
||||
const parentSha = targetCommit.parents[0].sha;
|
||||
const requiredContexts = [
|
||||
'Lint',
|
||||
'Test',
|
||||
'Coverage',
|
||||
'Policy',
|
||||
'Edition',
|
||||
'Interface Integrity',
|
||||
'AI Behavior',
|
||||
'CLI Smoke',
|
||||
'Mock MCP',
|
||||
];
|
||||
async function requireSuccessfulAdmission(ref, label) {
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
const runs = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
filter: 'latest',
|
||||
per_page: 100,
|
||||
});
|
||||
const latestByName = new Map();
|
||||
for (const run of runs) {
|
||||
if (
|
||||
run.head_sha !== ref ||
|
||||
run.app?.slug !== 'github-actions' ||
|
||||
!requiredContexts.includes(run.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const current = latestByName.get(run.name);
|
||||
if (!current || run.id > current.id) {
|
||||
latestByName.set(run.name, run);
|
||||
}
|
||||
}
|
||||
const invalid = requiredContexts.filter((name) => {
|
||||
const run = latestByName.get(name);
|
||||
return !run || run.conclusion !== 'success';
|
||||
});
|
||||
if (invalid.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`${label} ${ref} lacks successful Code Admission contexts: ${invalid.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await requireSuccessfulAdmission(parentSha, 'Formula parent');
|
||||
await requireSuccessfulAdmission(targetSha, 'Formula target');
|
||||
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: context.payload.repository.default_branch,
|
||||
});
|
||||
const {data: containment} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
if (!['ahead', 'identical'].includes(containment.status)) {
|
||||
throw new Error(`${targetSha} is not contained in the protected default branch`);
|
||||
}
|
||||
|
||||
core.setOutput('parent_sha', parentSha);
|
||||
core.setOutput('formula_path', formulaPath);
|
||||
|
||||
- name: Mark Formula cache promotion in progress
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: Number('${{ steps.validate-target.outputs.check_run_id }}'),
|
||||
status: 'in_progress',
|
||||
started_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: 'Producing exact-SHA coverage baseline',
|
||||
summary: 'The trusted default-branch workflow is validating or producing the main-scoped cache.',
|
||||
},
|
||||
});
|
||||
|
||||
- name: Check out validated Formula-only target
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ steps.validate-target.outputs.target_sha }}
|
||||
|
||||
- name: Verify checked-out Formula-only identity
|
||||
shell: bash
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.validate-target.outputs.target_sha }}
|
||||
PARENT_SHA: ${{ steps.validate-target.outputs.parent_sha }}
|
||||
FORMULA_PATH: ${{ steps.validate-target.outputs.formula_path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$TARGET_SHA"
|
||||
test "$(git rev-parse HEAD^)" = "$PARENT_SHA"
|
||||
test "$(git diff --name-only --no-renames "$PARENT_SHA" "$TARGET_SHA")" = "$FORMULA_PATH"
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Restore existing target coverage profile
|
||||
id: target-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate existing target coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Restore exact Formula parent coverage profile
|
||||
id: parent-cache
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate promoted Formula parent profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold Formula baseline
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute cold Formula baseline
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save Formula main SHA coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Verify Formula main SHA coverage cache exists
|
||||
id: formula-target-cache-verification
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact Formula main SHA coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
- name: Complete Formula cache promotion acknowledgement
|
||||
if: ${{ always() && steps.validate-target.outputs.check_run_id != '' }}
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
PROMOTION_JOB_STATUS: ${{ job.status }}
|
||||
with:
|
||||
script: |
|
||||
const checkRunId = Number('${{ steps.validate-target.outputs.check_run_id }}');
|
||||
const targetSha = '${{ steps.validate-target.outputs.target_sha }}';
|
||||
const expectedExternalId = '${{ steps.validate-target.outputs.check_external_id }}';
|
||||
const {data: currentCheck} = await github.rest.checks.get({
|
||||
...context.repo,
|
||||
check_run_id: checkRunId,
|
||||
});
|
||||
if (
|
||||
currentCheck.head_sha !== targetSha ||
|
||||
currentCheck.name !== 'Coverage Baseline Cache' ||
|
||||
currentCheck.external_id !== expectedExternalId ||
|
||||
currentCheck.app?.slug !== 'github-actions'
|
||||
) {
|
||||
throw new Error('refusing to update a changed promotion acknowledgement');
|
||||
}
|
||||
const succeeded = process.env.PROMOTION_JOB_STATUS === 'success';
|
||||
await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: checkRunId,
|
||||
status: 'completed',
|
||||
conclusion: succeeded ? 'success' : 'failure',
|
||||
completed_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: succeeded
|
||||
? 'Exact-SHA coverage baseline is available'
|
||||
: 'Exact-SHA coverage baseline promotion failed',
|
||||
summary: succeeded
|
||||
? `Verified the main-scoped exact cache for ${targetSha}.`
|
||||
: `Promotion failed for ${targetSha}; rerun the failed Release job after correcting the producer.`,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
name: Coverage Baseline Repair
|
||||
|
||||
run-name: Repair coverage baseline from ${{ github.event_name }}
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [closed]
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
repository_dispatch:
|
||||
types: [coverage-baseline-repair]
|
||||
schedule:
|
||||
- cron: "23 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# pull_request_target and workflow_run are allowed to inspect only GitHub API
|
||||
# data and dispatch the trusted producer. GitHub deliberately makes both
|
||||
# triggers read-only for the default-branch cache, so all checkout and cache
|
||||
# writes live in repository_dispatch, schedule, or main-only workflow_dispatch.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
# Retain every pending repair for one target. actionlint v1.7.12's bundled
|
||||
# schema predates GitHub's concurrency.queue support.
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
dispatch-merged-pr:
|
||||
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
# Never check out or execute pull-request content in this privileged
|
||||
# base-owned event. Re-read the merged PR, bind every immutable identity,
|
||||
# prove the result is in main, and send only those values to the producer.
|
||||
- name: Dispatch trusted merged-PR repair
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const eventPull = context.payload.pull_request;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
const pullNumber = Number(eventPull?.number);
|
||||
const headSha = eventPull?.head?.sha;
|
||||
const baseRef = eventPull?.base?.ref;
|
||||
const mergeCommitSha = eventPull?.merge_commit_sha;
|
||||
if (
|
||||
context.payload.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
context.payload.repository?.default_branch !== 'main' ||
|
||||
!Number.isSafeInteger(pullNumber) ||
|
||||
pullNumber <= 0 ||
|
||||
!fullCommit.test(headSha || '') ||
|
||||
baseRef !== 'main' ||
|
||||
!fullCommit.test(mergeCommitSha || '')
|
||||
) {
|
||||
throw new Error('closed PR event has an invalid repository or revision identity');
|
||||
}
|
||||
|
||||
// REST base.sha follows the live base branch and can move after
|
||||
// merge. Bind the closed event's stable PR head snapshot and merge
|
||||
// facts, then authorize the target through main containment.
|
||||
function isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
) {
|
||||
return (
|
||||
currentPull?.number === pullNumber &&
|
||||
currentPull.state === 'closed' &&
|
||||
currentPull.merged === true &&
|
||||
typeof currentPull.merged_at === 'string' &&
|
||||
currentPull.merged_at.length > 0 &&
|
||||
currentPull.base?.ref === 'main' &&
|
||||
currentPull.head?.sha === headSha &&
|
||||
currentPull.merge_commit_sha === mergeCommitSha
|
||||
);
|
||||
}
|
||||
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (!isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
)) {
|
||||
throw new Error(`PR #${pullNumber} no longer matches the merged-main event`);
|
||||
}
|
||||
|
||||
async function requireMainContainment(targetSha) {
|
||||
let lastState = 'not checked';
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
try {
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
});
|
||||
const {data: comparison} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
lastState = comparison.status;
|
||||
if (['ahead', 'identical'].includes(comparison.status)) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastState = error.message;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${targetSha} is not contained in protected main after retries: ${lastState}`,
|
||||
);
|
||||
}
|
||||
await requireMainContainment(mergeCommitSha);
|
||||
|
||||
// Normal App or human merges emit a protected-main push run whose
|
||||
// CI producer owns this exact key. Give Actions event delivery a
|
||||
// short visibility window and avoid a duplicate full-suite repair.
|
||||
// A workflow-skip directive or suppressed built-in-token event has
|
||||
// no such run, so only that missing-event path reaches dispatch.
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
ciWorkflow.state !== 'active'
|
||||
) {
|
||||
throw new Error('protected CI workflow identity is not active or exact');
|
||||
}
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
const {data: workflowRuns} =
|
||||
await github.rest.actions.listWorkflowRunsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
event: 'push',
|
||||
per_page: 100,
|
||||
});
|
||||
const exactPushRun = workflowRuns.workflow_runs.find(run =>
|
||||
run.name === 'CI' &&
|
||||
run.workflow_id === ciWorkflow.id &&
|
||||
run.path === ciWorkflow.path &&
|
||||
run.event === 'push' &&
|
||||
run.head_sha === mergeCommitSha &&
|
||||
run.head_branch === 'main' &&
|
||||
['queued', 'in_progress', 'completed'].includes(run.status),
|
||||
);
|
||||
if (exactPushRun) {
|
||||
core.info(
|
||||
`CI push run ${exactPushRun.id} already owns the exact-SHA producer for ${mergeCommitSha}; repair dispatch is unnecessary.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt < 12) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
|
||||
// repository_dispatch is one of GitHub's explicit GITHUB_TOKEN
|
||||
// recursion exceptions and receives default-branch cache-write scope.
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'coverage-baseline-repair',
|
||||
client_payload: {
|
||||
source: 'merged_pr',
|
||||
pull_number: String(pullNumber),
|
||||
head_sha: headSha,
|
||||
merge_commit_sha: mergeCommitSha,
|
||||
source_run_id: String(context.runId),
|
||||
},
|
||||
});
|
||||
core.info(
|
||||
`Dispatched exact-SHA coverage repair for merged PR #${pullNumber} at ${mergeCommitSha}.`,
|
||||
);
|
||||
|
||||
dispatch-failed-ci:
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' &&
|
||||
github.event.workflow_run.name == 'CI' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.status == 'completed' &&
|
||||
github.event.workflow_run.conclusion != 'success'
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
# workflow_run cannot write the default-branch cache. Re-read the exact
|
||||
# completed CI run from Actions, bind it to the protected CI workflow and
|
||||
# main revision, then use the repository_dispatch recursion exception.
|
||||
- name: Dispatch trusted failed-CI repair
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const upstream = 'DingTalk-Real-AI/dingtalk-workspace-cli';
|
||||
const eventRun = context.payload.workflow_run;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
const runID = Number(eventRun?.id);
|
||||
const runAttempt = Number(eventRun?.run_attempt);
|
||||
const headSha = eventRun?.head_sha;
|
||||
const conclusion = eventRun?.conclusion;
|
||||
if (
|
||||
context.payload.repository?.full_name !== upstream ||
|
||||
context.payload.repository?.default_branch !== 'main' ||
|
||||
!Number.isSafeInteger(runID) ||
|
||||
runID <= 0 ||
|
||||
!Number.isSafeInteger(runAttempt) ||
|
||||
runAttempt <= 0 ||
|
||||
eventRun?.name !== 'CI' ||
|
||||
eventRun?.event !== 'push' ||
|
||||
eventRun?.head_branch !== 'main' ||
|
||||
eventRun?.status !== 'completed' ||
|
||||
typeof conclusion !== 'string' ||
|
||||
conclusion.length === 0 ||
|
||||
conclusion === 'success' ||
|
||||
!fullCommit.test(headSha || '')
|
||||
) {
|
||||
throw new Error('workflow_run event is not one completed non-success main CI push');
|
||||
}
|
||||
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
const {data: currentRun} = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: runID,
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
eventRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.id !== runID ||
|
||||
currentRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.name !== 'CI' ||
|
||||
currentRun.event !== 'push' ||
|
||||
currentRun.head_branch !== 'main' ||
|
||||
currentRun.head_sha !== headSha ||
|
||||
currentRun.run_attempt !== runAttempt ||
|
||||
currentRun.status !== 'completed' ||
|
||||
currentRun.conclusion !== conclusion ||
|
||||
currentRun.conclusion === 'success' ||
|
||||
currentRun.repository?.full_name !== upstream ||
|
||||
currentRun.head_repository?.full_name !== upstream
|
||||
) {
|
||||
throw new Error(`CI workflow run ${runID} no longer matches the completed event`);
|
||||
}
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'coverage-baseline-repair',
|
||||
client_payload: {
|
||||
source: 'failed_ci',
|
||||
workflow_run_id: String(runID),
|
||||
workflow_run_attempt: String(runAttempt),
|
||||
workflow_conclusion: conclusion,
|
||||
merge_commit_sha: headSha,
|
||||
source_run_id: String(context.runId),
|
||||
},
|
||||
});
|
||||
core.info(
|
||||
`Dispatched exact-SHA coverage repair for ${conclusion} CI run ${runID} at ${headSha}.`,
|
||||
);
|
||||
|
||||
repair:
|
||||
if: ${{ github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Resolve trusted main repair target
|
||||
id: resolve-target
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
if (
|
||||
context.payload.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
context.payload.repository?.default_branch !== 'main'
|
||||
) {
|
||||
throw new Error('coverage repair is restricted to the protected upstream repository');
|
||||
}
|
||||
|
||||
async function requireMainContainment(targetSha) {
|
||||
let lastState = 'not checked';
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
try {
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
});
|
||||
const {data: comparison} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
lastState = comparison.status;
|
||||
if (['ahead', 'identical'].includes(comparison.status)) {
|
||||
return branch.commit.sha;
|
||||
}
|
||||
} catch (error) {
|
||||
lastState = error.message;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${targetSha} is not contained in protected main after retries: ${lastState}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The dispatcher froze the stable PR head snapshot in this payload.
|
||||
// Do not re-read mutable base.sha; bind the head and stable merge
|
||||
// facts, then prove protected-main containment below.
|
||||
function isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
) {
|
||||
return (
|
||||
currentPull?.number === pullNumber &&
|
||||
currentPull.state === 'closed' &&
|
||||
currentPull.merged === true &&
|
||||
typeof currentPull.merged_at === 'string' &&
|
||||
currentPull.merged_at.length > 0 &&
|
||||
currentPull.base?.ref === 'main' &&
|
||||
currentPull.head?.sha === headSha &&
|
||||
currentPull.merge_commit_sha === mergeCommitSha
|
||||
);
|
||||
}
|
||||
|
||||
let targetSha;
|
||||
if (context.eventName === 'repository_dispatch') {
|
||||
const payload = context.payload.client_payload || {};
|
||||
const sourceRunIDText = String(payload.source_run_id || '');
|
||||
if (!/^[1-9][0-9]*$/.test(sourceRunIDText)) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid source run');
|
||||
}
|
||||
if (payload.source === 'merged_pr') {
|
||||
const rawPullNumber = String(payload.pull_number || '');
|
||||
const pullNumber = Number(rawPullNumber);
|
||||
const headSha = payload.head_sha;
|
||||
targetSha = payload.merge_commit_sha;
|
||||
if (
|
||||
!/^[1-9][0-9]*$/.test(rawPullNumber) ||
|
||||
!Number.isSafeInteger(pullNumber) ||
|
||||
!fullCommit.test(headSha || '') ||
|
||||
!fullCommit.test(targetSha || '')
|
||||
) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid PR identity');
|
||||
}
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (!isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
targetSha,
|
||||
)) {
|
||||
throw new Error(
|
||||
`repair payload no longer matches merged PR #${pullNumber}`,
|
||||
);
|
||||
}
|
||||
} else if (payload.source === 'failed_ci') {
|
||||
const rawWorkflowRunID = String(payload.workflow_run_id || '');
|
||||
const workflowRunID = Number(rawWorkflowRunID);
|
||||
const rawWorkflowRunAttempt = String(payload.workflow_run_attempt || '');
|
||||
const workflowRunAttempt = Number(rawWorkflowRunAttempt);
|
||||
const workflowConclusion = payload.workflow_conclusion;
|
||||
targetSha = payload.merge_commit_sha;
|
||||
if (
|
||||
!/^[1-9][0-9]*$/.test(rawWorkflowRunID) ||
|
||||
!Number.isSafeInteger(workflowRunID) ||
|
||||
!/^[1-9][0-9]*$/.test(rawWorkflowRunAttempt) ||
|
||||
!Number.isSafeInteger(workflowRunAttempt) ||
|
||||
typeof workflowConclusion !== 'string' ||
|
||||
workflowConclusion.length === 0 ||
|
||||
workflowConclusion === 'success' ||
|
||||
!fullCommit.test(targetSha || '')
|
||||
) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid CI identity');
|
||||
}
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
const {data: currentRun} = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: workflowRunID,
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
currentRun.id !== workflowRunID ||
|
||||
currentRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.name !== 'CI' ||
|
||||
currentRun.event !== 'push' ||
|
||||
currentRun.head_branch !== 'main' ||
|
||||
currentRun.head_sha !== targetSha ||
|
||||
currentRun.run_attempt !== workflowRunAttempt ||
|
||||
currentRun.status !== 'completed' ||
|
||||
currentRun.conclusion !== workflowConclusion ||
|
||||
currentRun.conclusion === 'success' ||
|
||||
currentRun.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
currentRun.head_repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli'
|
||||
) {
|
||||
throw new Error(
|
||||
`repair payload no longer matches failed CI run ${workflowRunID}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error('coverage-baseline-repair payload has an unknown source');
|
||||
}
|
||||
await requireMainContainment(targetSha);
|
||||
} else {
|
||||
if (context.ref !== 'refs/heads/main') {
|
||||
throw new Error('scheduled and manual repair must run from refs/heads/main');
|
||||
}
|
||||
// github.sha is the default-branch tip that keyed this workflow's
|
||||
// concurrency group. Keep the producer bound to that exact
|
||||
// event-time target even if main advances while this run queues.
|
||||
targetSha = context.sha;
|
||||
if (!fullCommit.test(targetSha || '')) {
|
||||
throw new Error('protected main did not resolve to one full commit SHA');
|
||||
}
|
||||
await requireMainContainment(targetSha);
|
||||
}
|
||||
core.setOutput('target_sha', targetSha);
|
||||
core.info(`Resolved protected-main coverage repair target ${targetSha}.`);
|
||||
|
||||
- name: Check out exact protected-main target
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ steps.resolve-target.outputs.target_sha }}
|
||||
|
||||
- name: Verify checked-out repair target
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.resolve-target.outputs.target_sha }}
|
||||
run: test "$(git rev-parse HEAD)" = "$TARGET_SHA"
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Restore exact target coverage profile
|
||||
id: target-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate existing exact target profile
|
||||
if: steps.target-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold repair
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute complete target coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save exact protected-main coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
# Cache uploads are fail-open warnings. A lookup-only restore plus the
|
||||
# explicit cache-hit assertion makes an absent or partial key fail hard.
|
||||
- name: Verify exact protected-main coverage cache exists
|
||||
id: target-cache-verification
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact protected-main coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
@@ -1112,6 +1112,9 @@ jobs:
|
||||
needs: [release-contract, release-validation, release, verify-darwin-signatures]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}
|
||||
coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
@@ -1495,6 +1498,7 @@ jobs:
|
||||
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
|
||||
- name: Seal Formula-only Code Admission contexts
|
||||
id: seal-formula
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
@@ -1515,6 +1519,8 @@ jobs:
|
||||
const sourcePath = channel === "stable"
|
||||
? "dist/homebrew/dingtalk-workspace-cli.rb"
|
||||
: "dist/homebrew/dingtalk-workspace-cli-beta.rb";
|
||||
core.setOutput("coverage_baseline_required", "false");
|
||||
core.setOutput("coverage_baseline_commit", "");
|
||||
const expectedMessage = channel === "stable"
|
||||
? `chore: update formula for ${version} [skip ci]`
|
||||
: `chore: update beta formula for ${version} [skip ci]`;
|
||||
@@ -1650,6 +1656,11 @@ jobs:
|
||||
},
|
||||
});
|
||||
}
|
||||
core.setOutput("coverage_baseline_required", "true");
|
||||
core.setOutput("coverage_baseline_commit", commit);
|
||||
core.info(
|
||||
`Formula-only Code Admission is sealed for ${commit}; the independent confirmation job will dispatch its exact-SHA cache producer.`,
|
||||
);
|
||||
|
||||
- name: Reverify exact immutable npm package
|
||||
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
|
||||
@@ -2177,6 +2188,117 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
coverage-baseline-confirmation:
|
||||
name: Confirm Formula coverage baseline
|
||||
# Once Formula sealing has exposed a target SHA, later publication
|
||||
# verification failures must not orphan its exact-main cache producer.
|
||||
if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}
|
||||
needs: publish-release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require exact Formula cache acknowledgement
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}
|
||||
FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}
|
||||
with:
|
||||
script: |
|
||||
const rawRequired = process.env.BASELINE_REQUIRED;
|
||||
if (!['true', 'false'].includes(rawRequired)) {
|
||||
throw new Error(`Formula baseline requirement is invalid: ${rawRequired || 'empty'}`);
|
||||
}
|
||||
const required = rawRequired === 'true';
|
||||
const targetSha = process.env.FORMULA_COMMIT;
|
||||
if (!required) {
|
||||
if (targetSha) {
|
||||
throw new Error('Formula baseline outputs are inconsistent for a no-op publication');
|
||||
}
|
||||
core.info('Formula was already current; no new exact-SHA cache acknowledgement is required.');
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9a-f]{40}$/.test(targetSha)) {
|
||||
throw new Error('Formula baseline target output is malformed');
|
||||
}
|
||||
const expectedExternalId = `release-${context.runId}-${targetSha}`;
|
||||
let promotionCheck;
|
||||
try {
|
||||
const created = await github.rest.checks.create({
|
||||
...context.repo,
|
||||
name: 'Coverage Baseline Cache',
|
||||
head_sha: targetSha,
|
||||
status: 'queued',
|
||||
external_id: expectedExternalId,
|
||||
output: {
|
||||
title: 'Waiting for exact-SHA baseline promotion',
|
||||
summary:
|
||||
'The independent release governance job is waiting for the default-branch cache producer.',
|
||||
},
|
||||
});
|
||||
promotionCheck = created.data;
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
...context.repo,
|
||||
event_type: 'coverage-baseline-promote',
|
||||
client_payload: {
|
||||
target_sha: targetSha,
|
||||
source_run_id: String(context.runId),
|
||||
check_run_id: String(promotionCheck.id),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (promotionCheck) {
|
||||
try {
|
||||
await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: promotionCheck.id,
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
completed_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: 'Coverage baseline dispatch failed',
|
||||
summary: `Release could not dispatch the exact-SHA producer: ${error.message}`,
|
||||
},
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
core.error(
|
||||
`Could not close failed cache acknowledgement ${promotionCheck.id}: ${cleanupError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const checkRunId = promotionCheck.id;
|
||||
for (let attempt = 1; attempt <= 180; attempt += 1) {
|
||||
const {data: currentCheck} = await github.rest.checks.get({
|
||||
...context.repo,
|
||||
check_run_id: checkRunId,
|
||||
});
|
||||
if (
|
||||
currentCheck.head_sha !== targetSha ||
|
||||
currentCheck.name !== 'Coverage Baseline Cache' ||
|
||||
currentCheck.external_id !== expectedExternalId ||
|
||||
currentCheck.app?.slug !== 'github-actions'
|
||||
) {
|
||||
throw new Error('Formula baseline promotion acknowledgement changed identity');
|
||||
}
|
||||
if (currentCheck.status === 'completed') {
|
||||
if (currentCheck.conclusion !== 'success') {
|
||||
throw new Error(
|
||||
`Formula baseline promotion failed with ${currentCheck.conclusion || 'unknown'}`,
|
||||
);
|
||||
}
|
||||
core.info(`Formula baseline promotion completed for ${targetSha}.`);
|
||||
return;
|
||||
}
|
||||
if (attempt < 180) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
}
|
||||
}
|
||||
throw new Error(`Formula baseline promotion timed out for ${targetSha}`);
|
||||
|
||||
release-delivery-gate:
|
||||
name: Release delivery gate
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -2189,6 +2311,7 @@ jobs:
|
||||
- verify-darwin-signatures
|
||||
- publish-release
|
||||
- publish-channels
|
||||
- coverage-baseline-confirmation
|
||||
- mirror-gitee-release
|
||||
- repair-npm
|
||||
- repair-channel
|
||||
@@ -2211,6 +2334,7 @@ jobs:
|
||||
DARWIN_SIGNATURE_RESULT: ${{ needs.verify-darwin-signatures.result }}
|
||||
PUBLISH_RELEASE_RESULT: ${{ needs.publish-release.result }}
|
||||
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
|
||||
COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}
|
||||
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
|
||||
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
|
||||
REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}
|
||||
@@ -2234,6 +2358,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" success
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success
|
||||
if test "$GITEE_FALLBACK_ENABLED" = true; then
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" success
|
||||
else
|
||||
@@ -2273,6 +2398,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
@@ -2294,6 +2420,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
@@ -2309,6 +2436,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
require_cloud_jobs_skipped
|
||||
@@ -2323,6 +2451,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_cloud_jobs_skipped
|
||||
@@ -2337,6 +2466,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_cloud_jobs_skipped
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+168
-4
@@ -62,9 +62,124 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Reviewer Router GitHub App
|
||||
|
||||
Reviewer requests and native auto-merge intentionally use different
|
||||
identities. The base-owned `pull_request_target` workflow may use its built-in
|
||||
`GITHUB_TOKEN` to request reviewers, but it must mint a dedicated GitHub App
|
||||
installation token before enabling auto-merge. GitHub suppresses most workflow
|
||||
events created by the built-in token; using it for auto-merge prevents the
|
||||
merge commit's `push` workflows from running and leaves the exact-SHA Coverage
|
||||
baseline without a trusted main-scoped producer.
|
||||
|
||||
Configure the dedicated App before merging a workflow revision that requires
|
||||
it:
|
||||
|
||||
- install it only on `DingTalk-Real-AI/dingtalk-workspace-cli`;
|
||||
- grant only `Contents: read and write` and `Pull requests: read and write`;
|
||||
- set repository variable `REVIEWER_ROUTER_APP_CLIENT_ID` to its client ID;
|
||||
- set `REVIEWER_ROUTER_APP_SLUG` to its exact lowercase slug;
|
||||
- set repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY` to its private key;
|
||||
- create one active repository branch ruleset named `main-merge-writers`,
|
||||
targeting only `refs/heads/main`, with exactly one `Restrict updates` rule
|
||||
(`update_allows_fetch_and_merge: false`). GitHub may project that strict
|
||||
value through the read APIs as `{type: "update"}` with `parameters` omitted;
|
||||
consumers accept only that exact omission or a one-field `parameters` object
|
||||
containing explicit boolean `false`, and reject every other present shape or
|
||||
value. They then bind the same ruleset node through GraphQL and require its
|
||||
non-null `updateAllowsFetchAndMerge` value to be exactly `false`;
|
||||
- give that ruleset exactly two bypass actors: the Reviewer Router App as an
|
||||
`Integration` in `pull_request` mode, and `PeterGuy326` (ID `47820304`) in
|
||||
`always` mode for Formula publication and break-glass recovery;
|
||||
- never give the App bypass on `main-protection`, `main-quality`, or any other
|
||||
ruleset, and never reuse `HOMEBREW_PR_TOKEN`,
|
||||
`RELEASE_GOVERNANCE_TOKEN`, or a personal token for Reviewer Router.
|
||||
|
||||
The workflow limits each minted token to the current repository, requests the
|
||||
two permissions explicitly, and lets the token action revoke it at job end.
|
||||
It also requires the minted App slug to equal the reviewed repository variable;
|
||||
there is no `GITHUB_TOKEN` fallback. Before reading App credentials, the
|
||||
base-owned workflow revalidates the event's exact base/head and uses its
|
||||
built-in token only to disable an existing request owned by
|
||||
`github-actions[bot]` or one whose title or merge metadata requests that GitHub
|
||||
skip workflows. A mint or permission failure therefore leaves that PR
|
||||
manual-merge only. The built-in token's `Contents: write` permission is
|
||||
isolated to this trusted cleanup job and is never used to enable auto-merge;
|
||||
review routing keeps `Contents: read`. Existing requests owned by a human or
|
||||
another non-built-in identity are replaced with the exact dedicated-App
|
||||
request after token minting. Only an already App-owned request with the fixed
|
||||
headline/body is preserved. The required `Test` context reads the live
|
||||
repository settings and applied rulesets, verifies the exact writer-rule
|
||||
shape, and requires its own built-in Actions identity to report
|
||||
`current_user_can_bypass: never`. Before enabling or reconciling auto-merge,
|
||||
the minted App independently requires `pull_requests_only` on that writer rule
|
||||
and `never` on every other active main ruleset. These identity-relative checks
|
||||
remain available to low-privilege tokens; GitHub deliberately hides the full
|
||||
`bypass_actors` list from callers without ruleset-write access. Operators must
|
||||
therefore inspect that list during rollout and keep it at the exact two actors
|
||||
above. The required `Test` context then briefly waits for the concurrent
|
||||
router takeover and accepts only a null request or the configured App owner
|
||||
with exact fixed metadata. A null request is safe for this failure mode because
|
||||
the built-in Actions identity cannot pass the writer rule; other permitted
|
||||
identities emit either a protected-main push or the trusted closed-PR repair.
|
||||
Draft PRs skip this identity check; the explicit `ready_for_review` trigger
|
||||
reruns admission when they become merge-eligible,
|
||||
while `edited` and `auto_merge_enabled` rerun both workflows when the PR title
|
||||
or merge request changes. A human `auto_merge_disabled` event reruns CI without
|
||||
silently re-enabling the request, leaving it available only to the designated
|
||||
break-glass identity. The required `Test` context rejects GitHub workflow-skip
|
||||
directives in the PR title or an existing auto-merge request and verifies the
|
||||
repository's reviewed `MERGE_MESSAGE` title plus `PR_TITLE` or `BLANK` body
|
||||
defaults. GitHub does not expose those merge-related settings to the read-only
|
||||
admission token: the classifier accepts only both exact reviewed values or the
|
||||
complete omission of both properties, and rejects partial omission, `null`, or
|
||||
any other value. Before any enable or reconcile mutation, the dedicated App's
|
||||
current-repository token (which has `Contents: write`) must observe both exact
|
||||
reviewed values. The dedicated App binds the mutation to the exact head OID and
|
||||
supplies a fixed safe headline and body, so GitHub cannot copy an unsafe PR
|
||||
title into its merge commit.
|
||||
After enabling, the workflow requires the owner to equal the token action's
|
||||
exact `<app-slug>[bot]` output. If the event base/head changes during the
|
||||
mutation window, it removes only that App-owned request and fails the run.
|
||||
The break-glass publisher must preserve a safe final commit message;
|
||||
`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`,
|
||||
`[actions skip]`, and a `skip-checks: true` trailer are forbidden outside the
|
||||
release-controlled Formula-only path below.
|
||||
|
||||
GitHub may suppress `pull_request_target` entirely for security-sensitive head
|
||||
branch names, including names that look like commit SHAs. Such a PR receives
|
||||
neither App takeover nor the closed-event repair. Rename the head branch for
|
||||
the normal path; if break-glass merge is unavoidable, preserve a safe final
|
||||
message so the protected-main push CI remains the authoritative producer.
|
||||
|
||||
After installing the App, the protected-main push that deploys this workflow
|
||||
runs reconciliation automatically. The job enumerates open, ready `main` PRs
|
||||
with any non-App owner, unsafe App metadata, or workflow-skip metadata. It
|
||||
revalidates each base/head, converges a safe request to the exact dedicated-App
|
||||
owner and fixed message, and leaves a workflow-skipping request disabled for
|
||||
manual correction. It never enables auto-merge where the request was already
|
||||
null. A mid-migration failure leaves the affected PR disabled for a fresh
|
||||
routing event or break-glass merge. One PR failure is recorded
|
||||
without preventing later legacy owners from being attempted; the batch ends
|
||||
red with a per-PR summary. Manually dispatch `Reviewer routing` from `main`
|
||||
until the failed count is zero.
|
||||
|
||||
A PR that introduces or rotates this identity still runs the old base-owned
|
||||
router. Install/configure the App and activate the exact writer ruleset first;
|
||||
this blocks its legacy `github-actions[bot]` request from writing `main`. After
|
||||
the governance PR's final push, disable that old request, confirm the live
|
||||
settings/ruleset contract and all required checks are green for the exact head,
|
||||
then have only `PeterGuy326` merge that head with the repository-generated safe
|
||||
merge message. Verify the resulting merge SHA has a `CI` run with `event=push`,
|
||||
a successful `Coverage` context, and an exact-SHA baseline cache under
|
||||
`refs/heads/main`. Confirm automatic reconciliation reports zero failures and
|
||||
zero non-App owners. Finally use a normal canary PR to verify that the dedicated
|
||||
App is both `enabledBy` and `mergedBy`, and that the same post-merge chain
|
||||
repeats before declaring the rollout complete.
|
||||
|
||||
## Homebrew Formula Delivery
|
||||
|
||||
Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update
|
||||
Official releases use the designated `HOMEBREW_PR_TOKEN` identity to update
|
||||
exactly one tracked Formula after the immutable GitHub assets and their
|
||||
checksums have passed verification. The publisher validates the rendered Ruby,
|
||||
commits only the configured Formula path, never force-pushes `main`, and retries
|
||||
@@ -72,11 +187,60 @@ from a fresh clone up to three times when `main` advances concurrently. Normal
|
||||
stable and beta releases do not create a Formula PR or run a permission
|
||||
canary. The workflow uses the existing repository-scoped
|
||||
`HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in
|
||||
Actions App to bypass this repository's rulesets. That identity is the sole
|
||||
user bypass actor on the two default-branch rulesets. The workflow creates the
|
||||
Actions App to bypass this repository's rulesets. Its owner is the designated
|
||||
always-bypass actor for controlled Formula publication and break-glass recovery,
|
||||
including on `main-merge-writers`. The workflow creates the
|
||||
nine Code Admission checks for the Formula-only commit only after proving its
|
||||
sole parent already has all nine successful checks and the committed Formula
|
||||
exactly matches this release's verified bytes.
|
||||
exactly matches this release's verified bytes. Formula commits retain
|
||||
`[skip ci]`, so the sealing step exposes only the reviewed commit identity to
|
||||
an independent confirmation job. That job creates the
|
||||
`Coverage Baseline Cache` acknowledgement and emits the reviewed
|
||||
`coverage-baseline-promote` repository dispatch. The default-branch
|
||||
`Coverage Baseline Promotion` workflow independently verifies the exact
|
||||
single-parent Formula commit, both parent and target admission contexts, and
|
||||
default-branch containment before checking out the target. It restores only
|
||||
the exact parent profile, recomputes the complete profile if that cache is
|
||||
absent, and saves the Formula SHA under the `main` cache scope. Because the
|
||||
cache save action treats upload errors as warnings, a second lookup must report
|
||||
`cache-hit=true` for the exact target key before the producer succeeds. The
|
||||
promotion completes the unique acknowledgement, and the confirmation job
|
||||
waits for that exact check-run ID. npm and mirror publication depend only on
|
||||
the immutable release job, so a transient
|
||||
cache-service failure cannot strand an otherwise valid release between
|
||||
channels; the final release-delivery gate still fails until the exact cache is
|
||||
confirmed. Once Formula sealing exposes the target SHA, the confirmation job
|
||||
also runs when a later immutable-package recheck fails, so a post-push failure
|
||||
cannot orphan the producer. Rerun the failed promotion/confirmation path after
|
||||
repairing the producer. Never add a prefix `restore-keys` fallback to this path.
|
||||
|
||||
`Coverage Baseline Repair` is the independent safety net for every merged PR.
|
||||
Its base-owned `pull_request_target: closed` job never checks out or executes PR
|
||||
content: it binds the closed event's PR number and stable head SHA to the
|
||||
current merged-PR facts (`merged_at`, `base.ref`, and `merge_commit_sha`) and
|
||||
proves that merge commit is contained in `main`. It deliberately does not
|
||||
compare REST `base.sha`, because that field follows the live base branch and
|
||||
can move after the merge. Only then does it emit a
|
||||
`coverage-baseline-repair` repository dispatch. Workflow-skip directives alone
|
||||
do not suppress `pull_request_target`, subject to GitHub's separate
|
||||
security-sensitive branch-name restriction described above. The low-trust
|
||||
trigger is forbidden from writing the default-branch cache directly. Before
|
||||
dispatching, it gives Actions event delivery one minute to expose a run from
|
||||
the exact protected `.github/workflows/ci.yml` workflow and exits if that normal producer already
|
||||
owns the SHA, avoiding a duplicate full-suite run. A successful CI producer
|
||||
must hard-verify its exact cache key. If that run instead completes with any
|
||||
non-success conclusion, a separate base-owned `workflow_run` dispatcher binds
|
||||
the exact workflow ID/path, run ID/attempt, conclusion, repository, branch, and
|
||||
head SHA before requesting repair. `workflow_run` also has read-only
|
||||
default-branch cache access, so both dispatchers use the reviewed
|
||||
`repository_dispatch` exception. The dispatched default-branch producer
|
||||
revalidates the corresponding merged-PR or failed-CI identity before checkout,
|
||||
restores only the exact target key, recomputes the complete profile on a miss,
|
||||
and verifies `cache-hit=true` after saving. An hourly schedule refreshes the
|
||||
event-time `main` SHA after direct break-glass pushes or cache eviction;
|
||||
`workflow_dispatch` provides the same current-main repair on demand. The
|
||||
dedicated App identity remains mandatory because events created by the built-in
|
||||
`GITHUB_TOKEN` can suppress both the main push and the closed-PR event.
|
||||
|
||||
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
|
||||
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
|
||||
|
||||
+139
-9
@@ -147,13 +147,68 @@ maintainer pool. A current-head approval or change request is preserved; after
|
||||
a new push, stale activity does not suppress a fresh request, and an
|
||||
outstanding change requester is preferred for continuity.
|
||||
|
||||
The branch ruleset keeps one human approval and all nine strict required
|
||||
contexts, and requires someone other than the latest pusher to approve after
|
||||
the most recent head update. Repository auto-merge is enabled for ready PRs,
|
||||
so a PR merges after that approval and the current revision's nine checks are
|
||||
green. If `main` advances, strict checks rerun before merge. The reviewer
|
||||
router is orchestration, not a quality context, and must not be added to the
|
||||
ruleset.
|
||||
The branch rulesets keep one human approval and all nine strict required
|
||||
contexts, require someone other than the latest pusher to approve after the
|
||||
most recent head update, and restrict `main` updates to the dedicated Reviewer
|
||||
Router App in pull-request mode plus the designated Formula publisher in
|
||||
always/break-glass mode. Repository auto-merge is enabled for ready PRs, so the
|
||||
App-owned request merges after that approval and the current revision's nine
|
||||
checks are green. If `main` advances, strict checks rerun before merge. The
|
||||
reviewer routing job uses the built-in `GITHUB_TOKEN` to request reviewers with
|
||||
`Contents: read` and `Pull requests: write`. A separate base-owned cleanup job
|
||||
isolates the merge-authority permissions (`Contents: write` and `Pull
|
||||
requests: write`), revalidates the exact event base/head, and uses the built-in
|
||||
token only to disable an existing request owned by `github-actions[bot]` or one
|
||||
whose title or merge metadata requests that GitHub skip workflows; it never
|
||||
enables auto-merge. The job then mints a current-repository installation token
|
||||
for the dedicated Reviewer Router GitHub App, proves its emitted slug matches
|
||||
the reviewed `REVIEWER_ROUTER_APP_SLUG`, replaces every non-App request, and
|
||||
enables native auto-merge with fixed metadata. This
|
||||
identity boundary is required because GitHub suppresses
|
||||
most workflow events created by the built-in token; using it for auto-merge would
|
||||
silently skip the merge commit's protected-main CI and baseline-cache
|
||||
producer. Token minting or takeover fails closed without falling back to
|
||||
`GITHUB_TOKEN`: the unsafe request is cleared before credentials are read, and
|
||||
the required `Test` context live-verifies the exact `main-merge-writers` update
|
||||
rule. GitHub's read APIs may omit `parameters` for the strict
|
||||
`update_allows_fetch_and_merge: false` value, so the gate accepts only that
|
||||
exact omission or a one-field `parameters` object containing explicit boolean
|
||||
`false`; every other present shape or value fails closed. The gate then binds
|
||||
the same ruleset node through GraphQL and requires its non-null
|
||||
`updateAllowsFetchAndMerge` value to be exactly `false`. It also requires its
|
||||
own built-in token to report
|
||||
`current_user_can_bypass: never`. The minted App separately requires
|
||||
`pull_requests_only` on that writer rule and `never` on every other active main
|
||||
ruleset before it can enable or reconcile auto-merge. The read-only `Test`
|
||||
token may receive a repository projection with both merge-default properties
|
||||
omitted; it accepts only that complete omission or exact `MERGE_MESSAGE` plus
|
||||
`PR_TITLE`/`BLANK`, while partial or malformed projections fail closed. The
|
||||
minted App's `Contents: write` token must observe the exact reviewed defaults
|
||||
before either mutation path proceeds. GitHub hides the complete
|
||||
`bypass_actors` list from low-privilege callers, so the rollout audit must still
|
||||
keep the writer list at exactly the Reviewer App plus `PeterGuy326` (ID
|
||||
`47820304`). The required check finally accepts a null or exact App-owned
|
||||
request after a short takeover grace period. Null is safe from the suppressed
|
||||
event path because the built-in Actions identity cannot update `main`; other
|
||||
permitted identities produce either a main push or the trusted closed-PR
|
||||
repair. Drafts skip the identity step, while `ready_for_review`, `edited`,
|
||||
`auto_merge_enabled`, and `auto_merge_disabled` explicitly start fresh admission
|
||||
for readiness, title, and merge-request changes. Router does not react to
|
||||
`auto_merge_disabled`, so a
|
||||
human can deliberately leave the PR manual-only for break-glass handling.
|
||||
Reviewer routing remains available. The protected-main push that deploys the
|
||||
workflow automatically migrates every open, ready non-App request and repairs
|
||||
unsafe App metadata; it disables workflow-skipping requests for correction. A
|
||||
manual `workflow_dispatch` from `main` is the idempotent retry path.
|
||||
Reconciliation never enables an originally null request. The reviewer router
|
||||
is orchestration, not a quality context, and
|
||||
must not be added to the ruleset.
|
||||
|
||||
GitHub may omit `pull_request_target` for security-sensitive head branch names,
|
||||
including names that look like commit SHAs. Those PRs cannot use Router App
|
||||
takeover or the closed-event repair: rename the branch for the supported path,
|
||||
or use the designated break-glass identity with a safe final message so main
|
||||
push CI remains the exact-SHA producer.
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
@@ -213,7 +268,75 @@ the same dedicated cache profile path because GitHub includes that path in the
|
||||
cache version; the runtime-facing candidate and baseline filenames remain
|
||||
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
|
||||
keys, because a neighbouring commit's profile would compare the candidate
|
||||
against the wrong baseline. Supporting and (when
|
||||
against the wrong baseline. CI concurrency is keyed by PR number plus exact
|
||||
event base/head SHA. Duplicate runs for that exact revision may cancel each
|
||||
other, but a later revision cannot kill an earlier cold-cache producer. Main
|
||||
runs use the pushed SHA, so a newer main push cannot cancel a predecessor's
|
||||
producer.
|
||||
|
||||
Every supported main advancement path has an exact-SHA producer. The required
|
||||
`Test` context rejects GitHub workflow-skip directives in PR and auto-merge
|
||||
metadata, reruns when that metadata is enabled, disabled, or edited, and
|
||||
verifies the live App/writer-ruleset identity contract. Reviewer Router
|
||||
additionally binds auto-merge to the exact head OID and writes a fixed safe
|
||||
merge headline/body. The sole break-glass publisher must retain a safe final
|
||||
message; the release-controlled Formula-only path
|
||||
is the sole supported use of `[skip ci]`. A full source push
|
||||
saves the assembled profile after the aggregate gate passes. A trusted
|
||||
documentation or release-seal push independently verifies that the complete
|
||||
`before...after` diff contains only the reviewed metadata allowlist, restores
|
||||
only the exact `before` cache, recomputes the full profile if the chain is
|
||||
cold, and makes that helper a dependency of the required `Coverage` context.
|
||||
Release-generated Formula commits intentionally retain `[skip ci]`; after
|
||||
their nine synthetic contexts are sealed, an independent release-governance
|
||||
job creates an acknowledgement and emits a `coverage-baseline-promote`
|
||||
repository dispatch. The default-branch promotion
|
||||
workflow revalidates the exact single-parent Formula identity, successful
|
||||
parent and target contexts, and main containment before it promotes the exact
|
||||
parent cache or performs the same full fallback. Every target-main producer
|
||||
follows its save with a lookup-only restore and requires
|
||||
`cache-hit=true` for the exact key; this turns the cache action's otherwise
|
||||
warning-only upload failure or prefix match into a hard failure. Formula
|
||||
promotion additionally updates one release-created `Coverage Baseline Cache`
|
||||
check. A separate confirmation job waits for that exact check-run ID while npm
|
||||
and mirrors remain dependent only on the immutable publication job; cache
|
||||
failure therefore makes the final delivery gate red without creating a
|
||||
partially published release. Once Formula sealing exposes its SHA, a later
|
||||
publication verification failure cannot suppress that confirmation job.
|
||||
|
||||
A separate base-owned `pull_request_target: closed` safety net covers the final
|
||||
merged SHA even if a human or integration changes the merge message after PR
|
||||
checks finish. Skip directives alone do not suppress `pull_request_target`,
|
||||
subject to GitHub's separate security-sensitive branch-name restriction above.
|
||||
That job executes no PR code and only dispatches after binding the exact
|
||||
closed-event PR number and stable head SHA to merged-PR facts
|
||||
(`merged_at`, `base.ref`, and `merge_commit_sha`) and proving `main`
|
||||
containment. It does not compare the later REST `base.sha`, which follows the
|
||||
live base branch after merge. Because GitHub makes default-branch caches
|
||||
read-only to `pull_request_target`, the dispatcher first waits up to one minute
|
||||
for a run from the exact protected
|
||||
`.github/workflows/ci.yml` workflow and exits when that normal producer exists.
|
||||
A successful main CI hard-verifies the exact key itself. A completed
|
||||
non-success run starts a separate base-owned `workflow_run` dispatcher, which
|
||||
binds the exact CI workflow ID/path, run ID/attempt, conclusion, upstream
|
||||
repository, `main` branch, and head SHA. That trigger is also cache-read-only,
|
||||
so either trusted dispatcher uses `repository_dispatch`; its producer
|
||||
revalidates the merged-PR or failed-CI identity, checks out the contained SHA,
|
||||
and produces/verifies the exact full cache.
|
||||
An hourly schedule and a main-only manual dispatch repair the event-time main
|
||||
SHA after a direct break-glass push or cache eviction. The dispatch exception
|
||||
is intentional: unlike an ordinary event created by `GITHUB_TOKEN`, GitHub
|
||||
allows `repository_dispatch` to start another workflow. A legacy built-in-token
|
||||
merge can suppress the closed event too, which is why the required `Test`
|
||||
identity gate and dedicated Reviewer Router App are still mandatory.
|
||||
|
||||
A cold miss can still occur during a producer race or after cache eviction,
|
||||
but it remains fail-safe: the PR recomputes the authoritative baseline with a
|
||||
30-minute job budget and saves a PR-scoped copy for same-PR reruns. It is no
|
||||
longer possible for a supported main-advance path to omit its producer
|
||||
silently. That PR-scoped fallback save remains a best-effort acceleration and
|
||||
does not replace the normal push, metadata, Formula, and merged-PR repair
|
||||
producers. Supporting and (when
|
||||
platform-selected) native profiles are generated before the aggregate
|
||||
`Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
@@ -265,4 +388,11 @@ unproducible required context.
|
||||
|
||||
The branch ruleset also requires one approval after the latest push. Enable
|
||||
repository auto-merge and automatic head-branch deletion; keep the base-owned
|
||||
reviewer router outside the required-context list.
|
||||
reviewer router outside the required-context list. Install its dedicated
|
||||
GitHub App only on this repository with `Contents: read and write` and `Pull
|
||||
requests: read and write`; do not grant Actions, Workflows, or Administration.
|
||||
Give it pull-request-only bypass on `main-merge-writers` and no bypass on any
|
||||
other ruleset. Store the App client ID and lowercase slug in repository
|
||||
variables `REVIEWER_ROUTER_APP_CLIENT_ID` and `REVIEWER_ROUTER_APP_SLUG`, and
|
||||
its private key in repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY`. Do not
|
||||
reuse release, Homebrew, or personal tokens for this boundary.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const stableMergedPRIdentityHelper = `function isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
) {
|
||||
return (
|
||||
currentPull?.number === pullNumber &&
|
||||
currentPull.state === 'closed' &&
|
||||
currentPull.merged === true &&
|
||||
typeof currentPull.merged_at === 'string' &&
|
||||
currentPull.merged_at.length > 0 &&
|
||||
currentPull.base?.ref === 'main' &&
|
||||
currentPull.head?.sha === headSha &&
|
||||
currentPull.merge_commit_sha === mergeCommitSha
|
||||
);
|
||||
}`
|
||||
|
||||
func TestCoverageBaselineRepairUsesStableMergedPRIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to verify merged-PR repair identity semantics")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
workflowPath := filepath.Join(
|
||||
root,
|
||||
".github",
|
||||
"workflows",
|
||||
"coverage-baseline-repair.yml",
|
||||
)
|
||||
data, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", workflowPath, err)
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
var parsedWorkflow any
|
||||
if err := yaml.Unmarshal(data, &parsedWorkflow); err != nil {
|
||||
t.Fatalf("parse %s: %v", workflowPath, err)
|
||||
}
|
||||
var scripts []string
|
||||
collectGitHubScripts(parsedWorkflow, &scripts)
|
||||
helperCount := 0
|
||||
checkCount := 0
|
||||
for _, script := range scripts {
|
||||
helperCount += strings.Count(script, stableMergedPRIdentityHelper)
|
||||
checkCount += strings.Count(script, "!isStableMergedPRIdentity(")
|
||||
}
|
||||
if helperCount != 2 {
|
||||
t.Fatalf("stable merged-PR identity helper occurrences = %d, want dispatcher and producer", helperCount)
|
||||
}
|
||||
if checkCount != 2 {
|
||||
t.Fatalf("stable merged-PR identity checks = %d, want dispatcher and producer", checkCount)
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"currentPull.base.sha",
|
||||
"const baseSha =",
|
||||
"base_sha",
|
||||
} {
|
||||
if strings.Contains(workflow, forbidden) {
|
||||
t.Errorf("merged-PR repair must not bind mutable Ref SHA via %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"const baseRef = eventPull?.base?.ref;",
|
||||
"baseRef !== 'main'",
|
||||
"const headSha = eventPull?.head?.sha;",
|
||||
"const headSha = payload.head_sha;",
|
||||
"head_sha: headSha",
|
||||
"currentPull.head?.sha === headSha",
|
||||
"currentPull.merge_commit_sha === mergeCommitSha",
|
||||
"await requireMainContainment(mergeCommitSha)",
|
||||
"await requireMainContainment(targetSha)",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Errorf("merged-PR repair missing stable identity guard %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
verification := stableMergedPRIdentityHelper + `
|
||||
const pullNumber = 1077;
|
||||
const headSha = '1'.repeat(40);
|
||||
const mergeCommitSha = '2'.repeat(40);
|
||||
const validPull = {
|
||||
number: pullNumber,
|
||||
state: 'closed',
|
||||
merged: true,
|
||||
merged_at: '2026-08-25T04:55:30Z',
|
||||
base: {ref: 'main', sha: '3'.repeat(40)},
|
||||
head: {sha: headSha},
|
||||
merge_commit_sha: mergeCommitSha,
|
||||
};
|
||||
const cases = [
|
||||
['base ref advanced after event', {...validPull, base: {...validPull.base, sha: '4'.repeat(40)}}, true],
|
||||
['base sha omitted by projection', {...validPull, base: {ref: 'main'}}, true],
|
||||
['wrong pull number', {...validPull, number: pullNumber + 1}, false],
|
||||
['open pull request', {...validPull, state: 'open'}, false],
|
||||
['not merged', {...validPull, merged: false}, false],
|
||||
['missing merged at', {...validPull, merged_at: null}, false],
|
||||
['empty merged at', {...validPull, merged_at: ''}, false],
|
||||
['non-string merged at', {...validPull, merged_at: 1}, false],
|
||||
['wrong base ref', {...validPull, base: {...validPull.base, ref: 'release'}}, false],
|
||||
['missing base ref', {...validPull, base: {}}, false],
|
||||
['missing head', {...validPull, head: null}, false],
|
||||
['wrong head sha', {...validPull, head: {sha: '5'.repeat(40)}}, false],
|
||||
['wrong merge sha', {...validPull, merge_commit_sha: '6'.repeat(40)}, false],
|
||||
['null pull', null, false],
|
||||
];
|
||||
for (const [name, currentPull, want] of cases) {
|
||||
const got = isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
);
|
||||
if (got !== want) {
|
||||
throw new Error(name + ': got ' + got + ', want ' + want);
|
||||
}
|
||||
}
|
||||
`
|
||||
command := exec.Command(node, "-e", verification)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Fatalf("stable merged-PR identity verification failed: %v\n%s", runErr, output)
|
||||
}
|
||||
}
|
||||
@@ -135,15 +135,32 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
t.Fatalf("ReadFile(ci.yml) error = %v", err)
|
||||
}
|
||||
admission := string(data)
|
||||
for _, want := range []string{
|
||||
"group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}-{1}-{2}', github.event.pull_request.number, github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('push-{0}', github.sha) }}",
|
||||
"cancel-in-progress: true",
|
||||
} {
|
||||
if !strings.Contains(admission, want) {
|
||||
t.Errorf("CI workflow missing exact-revision producer contract %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"github.event.pull_request.number || github.ref",
|
||||
"github.event.pull_request.number || github.sha",
|
||||
} {
|
||||
if strings.Contains(admission, forbidden) {
|
||||
t.Errorf("CI producers must not share the coarse concurrency identity %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
currentStart := strings.Index(admission, "\n coverage-current:\n")
|
||||
fullStart := strings.Index(admission, "\n coverage-current-full:\n")
|
||||
supportingStart := strings.Index(admission, "\n coverage-supporting:\n")
|
||||
baselineStart := strings.Index(admission, "\n coverage-baseline:\n")
|
||||
metadataStart := strings.Index(admission, "\n coverage-main-metadata:\n")
|
||||
gateStart := strings.Index(admission, "\n coverage:\n")
|
||||
policyStart := strings.Index(admission, "\n policy:\n")
|
||||
if currentStart < 0 || fullStart <= currentStart || supportingStart <= fullStart ||
|
||||
baselineStart <= supportingStart || gateStart <= baselineStart || policyStart <= gateStart {
|
||||
baselineStart <= supportingStart || metadataStart <= baselineStart || gateStart <= metadataStart || policyStart <= gateStart {
|
||||
t.Fatal("CI workflow missing ordered coverage job boundaries")
|
||||
}
|
||||
|
||||
@@ -175,7 +192,10 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
baselineJob := admission[baselineStart:gateStart]
|
||||
baselineJob := admission[baselineStart:metadataStart]
|
||||
if !strings.Contains(baselineJob, "timeout-minutes: 30") {
|
||||
t.Error("coverage-baseline must retain enough headroom for an authoritative cold-cache fallback")
|
||||
}
|
||||
cachePath := "coverage-cache.txt"
|
||||
baselineKey := "dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}"
|
||||
for _, want := range []string{
|
||||
@@ -201,8 +221,50 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
t.Error("coverage baseline cache must stay exact-key; prefix restore-keys can resurrect a wrong-commit baseline")
|
||||
}
|
||||
|
||||
metadataJob := admission[metadataStart:gateStart]
|
||||
for _, want := range []string{
|
||||
"github.event_name == 'push'",
|
||||
"needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true'",
|
||||
"timeout-minutes: 30",
|
||||
"fetch-depth: 0",
|
||||
"PUSH_BEFORE_SHA: ${{ github.event.before }}",
|
||||
"PUSH_AFTER_SHA: ${{ github.event.after }}",
|
||||
"git merge-base --is-ancestor \"$PUSH_BEFORE_SHA\" \"$PUSH_AFTER_SHA\"",
|
||||
"git diff --name-only --no-renames -z",
|
||||
`^\.changes/[a-z0-9][a-z0-9._-]*\.md$`,
|
||||
`^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$`,
|
||||
"Refusing coverage-cache promotion for unreviewed change-fragment path",
|
||||
"Refusing coverage-cache promotion for executable path",
|
||||
"COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA",
|
||||
"id: metadata-current-cache",
|
||||
"id: metadata-source-cache",
|
||||
"key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}",
|
||||
"go test -count=1 -p 1",
|
||||
"./ ./cmd/... ./internal/... ./skills/...",
|
||||
"key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}",
|
||||
"id: metadata-target-cache-verification",
|
||||
"lookup-only: true",
|
||||
"fail-on-cache-miss: true",
|
||||
"EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}",
|
||||
`run: test "$EXACT_CACHE_HIT" = true`,
|
||||
} {
|
||||
if !strings.Contains(metadataJob, want) {
|
||||
t.Errorf("metadata-only main cache producer missing contract %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(metadataJob, "restore-keys") {
|
||||
t.Error("metadata-only main cache promotion must use exact source and target keys")
|
||||
}
|
||||
if strings.Count(metadataJob, "path: "+cachePath) != 4 {
|
||||
t.Error("metadata-only producer must restore target/source, save, and verify through the shared cache version path")
|
||||
}
|
||||
|
||||
gateJob := admission[gateStart:policyStart]
|
||||
for _, want := range []string{
|
||||
"- coverage-main-metadata",
|
||||
"MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }}",
|
||||
"main_metadata_expected=success",
|
||||
`"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected"`,
|
||||
"pattern: coverage-current-*",
|
||||
"merge-multiple: true",
|
||||
"for shard in app cli generators helpers remaining; do",
|
||||
@@ -212,6 +274,12 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
"cp coverage.txt coverage-cache.txt",
|
||||
"path: " + cachePath,
|
||||
"key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}",
|
||||
"name: Verify push coverage cache exists",
|
||||
"id: push-cache-verification",
|
||||
"lookup-only: true",
|
||||
"fail-on-cache-miss: true",
|
||||
"EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}",
|
||||
`run: test "$EXACT_CACHE_HIT" = true`,
|
||||
`"current shards:$CURRENT_FULL_RESULT:$current_full_expected"`,
|
||||
} {
|
||||
if !strings.Contains(gateJob, want) {
|
||||
@@ -219,7 +287,456 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Count(gateJob, "path: "+cachePath) != 1 {
|
||||
t.Error("green main push must save the candidate profile through the same cache path/version as baseline restore")
|
||||
if strings.Count(gateJob, "path: "+cachePath) != 2 {
|
||||
t.Error("green main push must save and verify the candidate profile through the same cache path/version as baseline restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormulaCoverageBaselinePromotionContract(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
promotionData, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "coverage-baseline-promotion.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(coverage-baseline-promotion.yml) error = %v", err)
|
||||
}
|
||||
promotion := string(promotionData)
|
||||
for _, want := range []string{
|
||||
"repository_dispatch:",
|
||||
"types: [coverage-baseline-promote]",
|
||||
"checks: write",
|
||||
"contents: read",
|
||||
"group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }}",
|
||||
"cancel-in-progress: false",
|
||||
"queue: max",
|
||||
"github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'",
|
||||
"timeout-minutes: 30",
|
||||
"context.payload.client_payload?.target_sha",
|
||||
"context.payload.client_payload?.source_run_id",
|
||||
"context.payload.client_payload?.check_run_id",
|
||||
"targetCommit.parents.length !== 1",
|
||||
"targetCommit.author?.login !== 'github-actions[bot]'",
|
||||
"targetCommit.committer?.login !== 'github-actions[bot]'",
|
||||
"files.length !== 1",
|
||||
"Formula/dingtalk-workspace-cli.rb",
|
||||
"Formula/dingtalk-workspace-cli-beta.rb",
|
||||
"[skip ci",
|
||||
"await requireSuccessfulAdmission(parentSha, 'Formula parent')",
|
||||
"await requireSuccessfulAdmission(targetSha, 'Formula target')",
|
||||
"for (let attempt = 1; attempt <= 6; attempt += 1)",
|
||||
"setTimeout(resolve, 5000)",
|
||||
"run.app?.slug !== 'github-actions'",
|
||||
"run.conclusion !== 'success'",
|
||||
"basehead: `${targetSha}...${branch.commit.sha}`",
|
||||
"['ahead', 'identical'].includes(containment.status)",
|
||||
"promotionCheck.name !== 'Coverage Baseline Cache'",
|
||||
"promotionCheck.external_id !== promotionExternalId",
|
||||
"promotionCheck.status !== 'queued'",
|
||||
"core.setOutput('check_run_id', String(checkRunId))",
|
||||
"name: Mark Formula cache promotion in progress",
|
||||
"status: 'in_progress'",
|
||||
"persist-credentials: false",
|
||||
"ref: ${{ steps.validate-target.outputs.target_sha }}",
|
||||
"test \"$(git rev-parse HEAD^)\" = \"$PARENT_SHA\"",
|
||||
"id: target-cache",
|
||||
"id: parent-cache",
|
||||
"key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }}",
|
||||
"go test -count=1 -p 1",
|
||||
"./ ./cmd/... ./internal/... ./skills/...",
|
||||
"key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}",
|
||||
"lookup-only: true",
|
||||
"fail-on-cache-miss: true",
|
||||
"id: formula-target-cache-verification",
|
||||
"EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }}",
|
||||
`run: test "$EXACT_CACHE_HIT" = true`,
|
||||
"name: Complete Formula cache promotion acknowledgement",
|
||||
"PROMOTION_JOB_STATUS: ${{ job.status }}",
|
||||
"conclusion: succeeded ? 'success' : 'failure'",
|
||||
} {
|
||||
if !strings.Contains(promotion, want) {
|
||||
t.Errorf("Formula baseline promotion missing contract %q", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
|
||||
"actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff",
|
||||
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
|
||||
"actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830",
|
||||
} {
|
||||
if !strings.Contains(promotion, want) {
|
||||
t.Errorf("Formula baseline promotion must pin trusted action %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"pull_request:",
|
||||
"pull_request_target:",
|
||||
"push:",
|
||||
"workflow_dispatch:",
|
||||
"restore-keys",
|
||||
"HOMEBREW_PR_TOKEN",
|
||||
"RELEASE_GOVERNANCE_TOKEN",
|
||||
"contents: write",
|
||||
} {
|
||||
if strings.Contains(promotion, forbidden) {
|
||||
t.Errorf("Formula baseline promotion must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
validateIndex := strings.Index(promotion, "name: Validate Formula-only main target")
|
||||
checkoutIndex := strings.Index(promotion, "name: Check out validated Formula-only target")
|
||||
if validateIndex < 0 || checkoutIndex <= validateIndex {
|
||||
t.Error("Formula target must be fully validated before it is checked out or executed")
|
||||
}
|
||||
ackReadIndex := strings.Index(promotion, "const {data: promotionCheck} = await github.rest.checks.get")
|
||||
ackOutputIndex := strings.Index(promotion, "core.setOutput('check_run_id', String(checkRunId))")
|
||||
targetReadIndex := strings.Index(promotion, "const {data: targetCommit} = await github.rest.repos.getCommit")
|
||||
if ackReadIndex < 0 || ackOutputIndex <= ackReadIndex || targetReadIndex <= ackOutputIndex {
|
||||
t.Error("Formula acknowledgement must be identity-checked and bound to the finalizer before target validation")
|
||||
}
|
||||
if strings.Count(promotion, "path: coverage-cache.txt") != 4 {
|
||||
t.Error("Formula promotion must restore target/source, save, and verify through the shared cache version path")
|
||||
}
|
||||
|
||||
releaseData, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(release.yml) error = %v", err)
|
||||
}
|
||||
release := string(releaseData)
|
||||
publishStart := strings.Index(release, " publish-release:\n")
|
||||
sealStart := strings.Index(release, " - name: Seal Formula-only Code Admission contexts\n")
|
||||
if publishStart < 0 || sealStart <= publishStart {
|
||||
t.Fatal("release workflow missing Formula-only seal start")
|
||||
}
|
||||
if !strings.Contains(release[publishStart:sealStart], "timeout-minutes: 30") {
|
||||
t.Error("publish-release must not be extended by cache acknowledgement polling")
|
||||
}
|
||||
sealEnd := strings.Index(release[sealStart:], "\n - name: Reverify exact immutable npm package\n")
|
||||
if sealEnd < 0 {
|
||||
t.Fatal("release workflow missing Formula-only seal boundaries")
|
||||
}
|
||||
seal := release[sealStart : sealStart+sealEnd]
|
||||
for _, want := range []string{
|
||||
`core.setOutput("coverage_baseline_required", "true")`,
|
||||
`core.setOutput("coverage_baseline_commit", commit)`,
|
||||
} {
|
||||
if !strings.Contains(seal, want) {
|
||||
t.Errorf("Formula seal missing cache-promotion dispatch contract %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"for (let attempt = 1; attempt <= 180; attempt += 1)",
|
||||
"github.rest.checks.get",
|
||||
"promotionComplete",
|
||||
"Coverage Baseline Cache",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
} {
|
||||
if strings.Contains(seal, forbidden) {
|
||||
t.Errorf("irreversible publish-release must not wait on cache through %q", forbidden)
|
||||
}
|
||||
}
|
||||
confirmationStart := strings.Index(release, " coverage-baseline-confirmation:\n")
|
||||
deliveryGateStart := strings.Index(release, " release-delivery-gate:\n")
|
||||
if confirmationStart < 0 || deliveryGateStart <= confirmationStart {
|
||||
t.Fatal("release workflow missing independent Formula baseline confirmation job")
|
||||
}
|
||||
confirmation := release[confirmationStart:deliveryGateStart]
|
||||
for _, want := range []string{
|
||||
"if (!['true', 'false'].includes(rawRequired))",
|
||||
"github.rest.checks.create",
|
||||
"name: 'Coverage Baseline Cache'",
|
||||
"status: 'queued'",
|
||||
"external_id: expectedExternalId",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"event_type: 'coverage-baseline-promote'",
|
||||
"check_run_id: String(promotionCheck.id)",
|
||||
"conclusion: 'failure'",
|
||||
"for (let attempt = 1; attempt <= 180; attempt += 1)",
|
||||
"github.rest.checks.get",
|
||||
"currentCheck.name !== 'Coverage Baseline Cache'",
|
||||
"currentCheck.conclusion !== 'success'",
|
||||
"setTimeout(resolve, 10000)",
|
||||
"Formula baseline promotion timed out",
|
||||
} {
|
||||
if !strings.Contains(confirmation, want) {
|
||||
t.Errorf("independent Formula baseline confirmation missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageBaselineRepairContract(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "coverage-baseline-repair.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(coverage-baseline-repair.yml) error = %v", err)
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"pull_request_target:",
|
||||
"branches: [main]",
|
||||
"types: [closed]",
|
||||
"workflow_run:",
|
||||
"workflows: [CI]",
|
||||
"types: [completed]",
|
||||
"repository_dispatch:",
|
||||
"types: [coverage-baseline-repair]",
|
||||
"schedule:",
|
||||
`cron: "23 * * * *"`,
|
||||
"workflow_dispatch:",
|
||||
"group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }}",
|
||||
"cancel-in-progress: false",
|
||||
"queue: max",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Errorf("coverage baseline repair missing workflow contract %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
dispatchStart := strings.Index(workflow, "\n dispatch-merged-pr:\n")
|
||||
failedDispatchStart := strings.Index(workflow, "\n dispatch-failed-ci:\n")
|
||||
repairStart := strings.Index(workflow, "\n repair:\n")
|
||||
if dispatchStart < 0 || failedDispatchStart <= dispatchStart || repairStart <= failedDispatchStart {
|
||||
t.Fatal("coverage baseline repair is missing ordered merged-PR, failed-CI, and producer jobs")
|
||||
}
|
||||
dispatcher := workflow[dispatchStart:failedDispatchStart]
|
||||
for _, want := range []string{
|
||||
"github.event_name == 'pull_request_target'",
|
||||
"github.event.pull_request.merged == true",
|
||||
"github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'",
|
||||
"actions: read",
|
||||
"contents: write",
|
||||
"pull-requests: read",
|
||||
"actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b",
|
||||
"context.payload.repository?.default_branch !== 'main'",
|
||||
"currentPull.state === 'closed'",
|
||||
"currentPull.merged === true",
|
||||
"typeof currentPull.merged_at === 'string'",
|
||||
"const baseRef = eventPull?.base?.ref;",
|
||||
"baseRef !== 'main'",
|
||||
"currentPull.base?.ref === 'main'",
|
||||
"isStableMergedPRIdentity(",
|
||||
"currentPull.head?.sha === headSha",
|
||||
"currentPull.merge_commit_sha === mergeCommitSha",
|
||||
"for (let attempt = 1; attempt <= 6; attempt += 1)",
|
||||
"setTimeout(resolve, 5000)",
|
||||
"basehead: `${targetSha}...${branch.commit.sha}`",
|
||||
"['ahead', 'identical'].includes(comparison.status)",
|
||||
"for (let attempt = 1; attempt <= 12; attempt += 1)",
|
||||
"github.rest.actions.getWorkflow",
|
||||
"workflow_id: '.github/workflows/ci.yml'",
|
||||
"ciWorkflow.name !== 'CI'",
|
||||
"ciWorkflow.path !== '.github/workflows/ci.yml'",
|
||||
"ciWorkflow.state !== 'active'",
|
||||
"github.rest.actions.listWorkflowRunsForRepo",
|
||||
"branch: 'main'",
|
||||
"event: 'push'",
|
||||
"run.name === 'CI'",
|
||||
"run.workflow_id === ciWorkflow.id",
|
||||
"run.path === ciWorkflow.path",
|
||||
"run.event === 'push'",
|
||||
"run.head_sha === mergeCommitSha",
|
||||
"run.head_branch === 'main'",
|
||||
"['queued', 'in_progress', 'completed'].includes(run.status)",
|
||||
"setTimeout(resolve, 5000)",
|
||||
"repair dispatch is unnecessary",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"event_type: 'coverage-baseline-repair'",
|
||||
"source: 'merged_pr'",
|
||||
"pull_number: String(pullNumber)",
|
||||
"head_sha: headSha",
|
||||
"merge_commit_sha: mergeCommitSha",
|
||||
} {
|
||||
if !strings.Contains(dispatcher, want) {
|
||||
t.Errorf("merged-PR repair dispatcher missing %q", want)
|
||||
}
|
||||
}
|
||||
ciWorkflowLookup := strings.Index(dispatcher, "github.rest.actions.getWorkflow")
|
||||
pushRunLookup := strings.Index(dispatcher, "github.rest.actions.listWorkflowRunsForRepo")
|
||||
repairDispatch := strings.Index(dispatcher, "github.rest.repos.createDispatchEvent")
|
||||
if ciWorkflowLookup < 0 || pushRunLookup <= ciWorkflowLookup || repairDispatch <= pushRunLookup {
|
||||
t.Error("merged-PR dispatcher must bind the fixed CI workflow before exhausting exact push-run lookup and dispatch")
|
||||
}
|
||||
stableIdentityCheck := strings.Index(dispatcher, "if (!isStableMergedPRIdentity(")
|
||||
mainContainmentCheck := strings.Index(dispatcher, "await requireMainContainment(mergeCommitSha)")
|
||||
if stableIdentityCheck < 0 || mainContainmentCheck <= stableIdentityCheck || repairDispatch <= mainContainmentCheck {
|
||||
t.Error("merged-PR dispatcher must prove stable PR identity and main containment before dispatch")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout@",
|
||||
"actions/cache/",
|
||||
"actions/setup-go@",
|
||||
"go test ",
|
||||
"github.event.pull_request.head.ref",
|
||||
"currentPull.base.sha",
|
||||
"base_sha",
|
||||
"secrets.",
|
||||
} {
|
||||
if strings.Contains(dispatcher, forbidden) {
|
||||
t.Errorf("privileged pull_request_target dispatcher must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
failedDispatcher := workflow[failedDispatchStart:repairStart]
|
||||
for _, want := range []string{
|
||||
"github.event_name == 'workflow_run'",
|
||||
"github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'",
|
||||
"github.event.workflow_run.name == 'CI'",
|
||||
"github.event.workflow_run.event == 'push'",
|
||||
"github.event.workflow_run.head_branch == 'main'",
|
||||
"github.event.workflow_run.status == 'completed'",
|
||||
"github.event.workflow_run.conclusion != 'success'",
|
||||
"actions: read",
|
||||
"contents: write",
|
||||
"actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b",
|
||||
"context.payload.repository?.default_branch !== 'main'",
|
||||
"eventRun?.name !== 'CI'",
|
||||
"eventRun?.event !== 'push'",
|
||||
"eventRun?.head_branch !== 'main'",
|
||||
"eventRun?.status !== 'completed'",
|
||||
"conclusion === 'success'",
|
||||
"github.rest.actions.getWorkflow",
|
||||
"workflow_id: '.github/workflows/ci.yml'",
|
||||
"github.rest.actions.getWorkflowRun",
|
||||
"eventRun.workflow_id !== ciWorkflow.id",
|
||||
"currentRun.workflow_id !== ciWorkflow.id",
|
||||
"currentRun.name !== 'CI'",
|
||||
"currentRun.event !== 'push'",
|
||||
"currentRun.head_branch !== 'main'",
|
||||
"currentRun.head_sha !== headSha",
|
||||
"currentRun.run_attempt !== runAttempt",
|
||||
"currentRun.status !== 'completed'",
|
||||
"currentRun.conclusion !== conclusion",
|
||||
"currentRun.repository?.full_name !== upstream",
|
||||
"currentRun.head_repository?.full_name !== upstream",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"event_type: 'coverage-baseline-repair'",
|
||||
"source: 'failed_ci'",
|
||||
"workflow_run_id: String(runID)",
|
||||
"workflow_run_attempt: String(runAttempt)",
|
||||
"workflow_conclusion: conclusion",
|
||||
"merge_commit_sha: headSha",
|
||||
} {
|
||||
if !strings.Contains(failedDispatcher, want) {
|
||||
t.Errorf("failed-CI repair dispatcher missing %q", want)
|
||||
}
|
||||
}
|
||||
failedRunRead := strings.Index(failedDispatcher, "github.rest.actions.getWorkflowRun")
|
||||
failedRepairDispatch := strings.Index(failedDispatcher, "github.rest.repos.createDispatchEvent")
|
||||
if failedRunRead < 0 || failedRepairDispatch <= failedRunRead {
|
||||
t.Error("workflow_run dispatcher must API-bind the exact failed CI run before repository dispatch")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout@",
|
||||
"actions/cache/",
|
||||
"actions/setup-go@",
|
||||
"go test ",
|
||||
"secrets.",
|
||||
} {
|
||||
if strings.Contains(failedDispatcher, forbidden) {
|
||||
t.Errorf("privileged workflow_run dispatcher must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
producer := workflow[repairStart:]
|
||||
for _, want := range []string{
|
||||
"github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'",
|
||||
"timeout-minutes: 35",
|
||||
"actions: read",
|
||||
"contents: read",
|
||||
"pull-requests: read",
|
||||
"id: resolve-target",
|
||||
"context.eventName === 'repository_dispatch'",
|
||||
"payload.source === 'merged_pr'",
|
||||
"payload.pull_number",
|
||||
"payload.head_sha",
|
||||
"payload.merge_commit_sha",
|
||||
"currentPull.state === 'closed'",
|
||||
"currentPull.merged === true",
|
||||
"typeof currentPull.merged_at === 'string'",
|
||||
"currentPull.base?.ref === 'main'",
|
||||
"isStableMergedPRIdentity(",
|
||||
"currentPull.head?.sha === headSha",
|
||||
"currentPull.merge_commit_sha === mergeCommitSha",
|
||||
"payload.source === 'failed_ci'",
|
||||
"payload.workflow_run_id",
|
||||
"payload.workflow_run_attempt",
|
||||
"payload.workflow_conclusion",
|
||||
"github.rest.actions.getWorkflow",
|
||||
"workflow_id: '.github/workflows/ci.yml'",
|
||||
"github.rest.actions.getWorkflowRun",
|
||||
"ciWorkflow.name !== 'CI'",
|
||||
"ciWorkflow.path !== '.github/workflows/ci.yml'",
|
||||
"currentRun.id !== workflowRunID",
|
||||
"currentRun.workflow_id !== ciWorkflow.id",
|
||||
"currentRun.name !== 'CI'",
|
||||
"currentRun.event !== 'push'",
|
||||
"currentRun.head_branch !== 'main'",
|
||||
"currentRun.head_sha !== targetSha",
|
||||
"currentRun.run_attempt !== workflowRunAttempt",
|
||||
"currentRun.status !== 'completed'",
|
||||
"currentRun.conclusion !== workflowConclusion",
|
||||
"coverage-baseline-repair payload has an unknown source",
|
||||
"context.ref !== 'refs/heads/main'",
|
||||
"targetSha = context.sha",
|
||||
"for (let attempt = 1; attempt <= 6; attempt += 1)",
|
||||
"basehead: `${targetSha}...${branch.commit.sha}`",
|
||||
"core.setOutput('target_sha', targetSha)",
|
||||
"actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
|
||||
"fetch-depth: 0",
|
||||
"persist-credentials: false",
|
||||
"ref: ${{ steps.resolve-target.outputs.target_sha }}",
|
||||
`run: test "$(git rev-parse HEAD)" = "$TARGET_SHA"`,
|
||||
"actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff",
|
||||
"id: target-cache",
|
||||
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
|
||||
"key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}",
|
||||
"go test -count=1 -p 1",
|
||||
"./ ./cmd/... ./internal/... ./skills/...",
|
||||
"actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830",
|
||||
"id: target-cache-verification",
|
||||
"lookup-only: true",
|
||||
"fail-on-cache-miss: true",
|
||||
"EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }}",
|
||||
`run: test "$EXACT_CACHE_HIT" = true`,
|
||||
} {
|
||||
if !strings.Contains(producer, want) {
|
||||
t.Errorf("trusted coverage repair producer missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"restore-keys",
|
||||
"github.event.pull_request.head.ref",
|
||||
"currentPull.base.sha",
|
||||
"base_sha",
|
||||
"HOMEBREW_PR_TOKEN",
|
||||
"RELEASE_GOVERNANCE_TOKEN",
|
||||
"REVIEWER_ROUTER_APP_PRIVATE_KEY",
|
||||
"contents: write",
|
||||
} {
|
||||
if strings.Contains(producer, forbidden) {
|
||||
t.Errorf("trusted coverage repair producer must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
if strings.Count(producer, "path: coverage-cache.txt") != 3 {
|
||||
t.Error("coverage repair must restore, save, and independently verify one exact cache path/version")
|
||||
}
|
||||
if strings.Count(producer, "key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}") != 3 {
|
||||
t.Error("coverage repair restore, save, and verification must share one exact target key")
|
||||
}
|
||||
stablePayloadIdentityCheck := strings.Index(producer, "if (!isStableMergedPRIdentity(")
|
||||
mainPayloadContainmentCheck := strings.Index(producer, "await requireMainContainment(targetSha)")
|
||||
resolvedTargetOutput := strings.Index(producer, "core.setOutput('target_sha', targetSha)")
|
||||
if stablePayloadIdentityCheck < 0 ||
|
||||
mainPayloadContainmentCheck <= stablePayloadIdentityCheck ||
|
||||
resolvedTargetOutput <= mainPayloadContainmentCheck {
|
||||
t.Error("merged-PR producer must prove stable payload identity and main containment before resolving checkout target")
|
||||
}
|
||||
validateIndex := strings.Index(producer, "name: Resolve trusted main repair target")
|
||||
checkoutIndex := strings.Index(producer, "name: Check out exact protected-main target")
|
||||
if validateIndex < 0 || checkoutIndex <= validateIndex {
|
||||
t.Error("repository_dispatch input must be fully bound to its protected-main source before checkout")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2431,6 +2431,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
"- verify-darwin-signatures",
|
||||
"- publish-release",
|
||||
"- publish-channels",
|
||||
"- coverage-baseline-confirmation",
|
||||
"- mirror-gitee-release",
|
||||
"- repair-npm",
|
||||
"- repair-channel",
|
||||
@@ -2440,6 +2441,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
`RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}`,
|
||||
`RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}`,
|
||||
`SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}`,
|
||||
`COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}`,
|
||||
"require_publication",
|
||||
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
|
||||
`require_result release-validation "$RELEASE_VALIDATION_RESULT" success`,
|
||||
@@ -2447,6 +2449,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
`require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success`,
|
||||
`require_result publish-release "$PUBLISH_RELEASE_RESULT" success`,
|
||||
`require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`,
|
||||
`require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success`,
|
||||
`require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped`,
|
||||
"workflow_dispatch:recover_release",
|
||||
"workflow_dispatch:create_release",
|
||||
"workflow_dispatch:plan_release",
|
||||
@@ -2787,8 +2791,12 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, required := range []string{
|
||||
"timeout-minutes: 30",
|
||||
`coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}`,
|
||||
`coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}`,
|
||||
"id: homebrew-stable",
|
||||
"id: homebrew-beta",
|
||||
"id: seal-formula",
|
||||
`FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}`,
|
||||
`FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}`,
|
||||
} {
|
||||
@@ -2830,6 +2838,8 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
`head_sha: commit`,
|
||||
`status: "completed"`,
|
||||
`conclusion: "success"`,
|
||||
`core.setOutput("coverage_baseline_required", "true")`,
|
||||
`core.setOutput("coverage_baseline_commit", commit)`,
|
||||
} {
|
||||
if !strings.Contains(seal, required) {
|
||||
t.Errorf("Formula-only Code Admission sealing is missing %q", required)
|
||||
@@ -2841,12 +2851,17 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if strings.Count(seal, "github.rest.checks.create") != 1 {
|
||||
t.Error("Formula-only checks must be created only by the single verified context loop")
|
||||
t.Error("Formula sealing must write only the reviewed Code Admission contexts")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"head_sha: context.sha",
|
||||
"head_sha: parent",
|
||||
"head_sha: branch.data.commit.sha",
|
||||
"github.rest.checks.get",
|
||||
"promotionComplete",
|
||||
"setTimeout(resolve, 10000)",
|
||||
"Coverage Baseline Cache",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
} {
|
||||
if strings.Contains(seal, forbidden) {
|
||||
t.Errorf("Formula-only Code Admission must not mark an unverified head green: found %q", forbidden)
|
||||
@@ -2854,6 +2869,9 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
}
|
||||
|
||||
createCheck := strings.Index(seal, "github.rest.checks.create")
|
||||
if createCheck == -1 {
|
||||
t.Error("Formula sealing must create the verified Code Admission contexts")
|
||||
}
|
||||
for name, marker := range map[string]string{
|
||||
"single-parent Formula-only identity": "const exactFormulaCommit",
|
||||
"successful parent contexts": "invalidParentContexts.length > 0",
|
||||
@@ -2867,6 +2885,67 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowConfirmsFormulaCacheWithoutBlockingChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
|
||||
channelsJob := releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n")
|
||||
confirmation := releaseWorkflowSection(
|
||||
t,
|
||||
workflow,
|
||||
" coverage-baseline-confirmation:\n",
|
||||
"\n release-delivery-gate:\n",
|
||||
)
|
||||
|
||||
for _, required := range []string{
|
||||
`if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}`,
|
||||
"needs: publish-release",
|
||||
"timeout-minutes: 35",
|
||||
"checks: write",
|
||||
"contents: write",
|
||||
`BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}`,
|
||||
`FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}`,
|
||||
"if (!['true', 'false'].includes(rawRequired))",
|
||||
"Formula baseline requirement is invalid",
|
||||
"github.rest.checks.create",
|
||||
"name: 'Coverage Baseline Cache'",
|
||||
"status: 'queued'",
|
||||
"external_id: expectedExternalId",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"event_type: 'coverage-baseline-promote'",
|
||||
"source_run_id: String(context.runId)",
|
||||
"check_run_id: String(promotionCheck.id)",
|
||||
"github.rest.checks.update",
|
||||
"conclusion: 'failure'",
|
||||
"github.rest.checks.get",
|
||||
"currentCheck.head_sha !== targetSha",
|
||||
"currentCheck.external_id !== expectedExternalId",
|
||||
"currentCheck.app?.slug !== 'github-actions'",
|
||||
"for (let attempt = 1; attempt <= 180; attempt += 1)",
|
||||
"currentCheck.conclusion !== 'success'",
|
||||
"await new Promise(resolve => setTimeout(resolve, 10000))",
|
||||
"Formula baseline promotion timed out",
|
||||
} {
|
||||
if !strings.Contains(confirmation, required) {
|
||||
t.Errorf("Formula baseline confirmation missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(channelsJob, "coverage-baseline-confirmation") ||
|
||||
strings.Contains(channelsJob, "needs.coverage-baseline-confirmation") {
|
||||
t.Error("cache acknowledgement must not block npm or mirror publication")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"Coverage Baseline Cache",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"github.rest.checks.get",
|
||||
"promotionComplete",
|
||||
} {
|
||||
if strings.Contains(publishJob, forbidden) {
|
||||
t.Errorf("irreversible publication job must not own cache lifecycle marker %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowWaitsForNPMDistTagPropagation(t *testing.T) {
|
||||
workflow := readReleaseWorkflow(t)
|
||||
script := releaseWorkflowRunScript(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const mergeDefaultsProjectionHelper = `function classifyMergeDefaults(repository) {
|
||||
if (
|
||||
repository === null ||
|
||||
typeof repository !== 'object' ||
|
||||
Array.isArray(repository)
|
||||
) {
|
||||
return 'invalid';
|
||||
}
|
||||
const hasTitle = Object.prototype.hasOwnProperty.call(
|
||||
repository,
|
||||
'merge_commit_title',
|
||||
);
|
||||
const hasMessage = Object.prototype.hasOwnProperty.call(
|
||||
repository,
|
||||
'merge_commit_message',
|
||||
);
|
||||
if (!hasTitle && !hasMessage) {
|
||||
return 'omitted';
|
||||
}
|
||||
if (!hasTitle || !hasMessage) {
|
||||
return 'invalid';
|
||||
}
|
||||
if (
|
||||
repository.merge_commit_title === 'MERGE_MESSAGE' &&
|
||||
['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message)
|
||||
) {
|
||||
return 'reviewed';
|
||||
}
|
||||
return 'invalid';
|
||||
}`
|
||||
|
||||
const privilegedMergeDefaultsGuard = `if (mergeDefaultsProjection !== 'reviewed') {
|
||||
throw new Error(
|
||||
'Dedicated Reviewer Router App cannot verify the reviewed repository merge-message defaults.',
|
||||
);
|
||||
}`
|
||||
|
||||
func TestReviewerRouterMergeDefaultsProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to verify merge-default projection semantics")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
|
||||
var scripts []string
|
||||
for _, relativePath := range []string{
|
||||
filepath.Join(".github", "workflows", "ci.yml"),
|
||||
filepath.Join(".github", "workflows", "reviewer-router.yml"),
|
||||
} {
|
||||
path := filepath.Join(root, relativePath)
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s: %v", path, readErr)
|
||||
}
|
||||
var workflow any
|
||||
if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil {
|
||||
t.Fatalf("parse %s: %v", path, unmarshalErr)
|
||||
}
|
||||
collectGitHubScripts(workflow, &scripts)
|
||||
}
|
||||
|
||||
checked := 0
|
||||
readOnlyChecks := 0
|
||||
privilegedChecks := 0
|
||||
for _, script := range scripts {
|
||||
if strings.Count(script, mergeDefaultsProjectionHelper) != 1 {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
for _, marker := range []string{
|
||||
"github.rest.repos.get",
|
||||
"const mergeDefaultsProjection = classifyMergeDefaults(repository);",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("merge-default script does not enforce marker %q", marker)
|
||||
}
|
||||
}
|
||||
if strings.Contains(script, "MINTED_REVIEWER_ROUTER_APP_SLUG") {
|
||||
privilegedChecks++
|
||||
for _, marker := range []string{
|
||||
privilegedMergeDefaultsGuard,
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("privileged merge-default script missing marker %q", marker)
|
||||
}
|
||||
}
|
||||
guardIndex := strings.Index(script, privilegedMergeDefaultsGuard)
|
||||
guardEndIndex := guardIndex + len(privilegedMergeDefaultsGuard)
|
||||
firstMutationIndex := len(script)
|
||||
for _, mutation := range []string{
|
||||
"disablePullRequestAutoMerge",
|
||||
"enablePullRequestAutoMerge",
|
||||
} {
|
||||
if index := strings.Index(script, mutation); index >= 0 && index < firstMutationIndex {
|
||||
firstMutationIndex = index
|
||||
}
|
||||
}
|
||||
if guardIndex < 0 || firstMutationIndex == len(script) || guardEndIndex >= firstMutationIndex {
|
||||
t.Error("privileged merge-default validation must precede every auto-merge mutation")
|
||||
}
|
||||
continue
|
||||
}
|
||||
readOnlyChecks++
|
||||
for _, marker := range []string{
|
||||
"mergeDefaultsProjection === 'invalid'",
|
||||
"mergeDefaultsProjection === 'omitted'",
|
||||
"Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("read-only merge-default script missing marker %q", marker)
|
||||
}
|
||||
}
|
||||
if strings.Contains(script, "mergeDefaultsProjection !== 'reviewed'") {
|
||||
t.Error("read-only CI must distinguish exact dual omission from malformed projections")
|
||||
}
|
||||
}
|
||||
if checked != 3 || readOnlyChecks != 1 || privilegedChecks != 2 {
|
||||
t.Fatalf(
|
||||
"merge-default scripts checked=%d read-only=%d privileged=%d, want 3/1/2",
|
||||
checked,
|
||||
readOnlyChecks,
|
||||
privilegedChecks,
|
||||
)
|
||||
}
|
||||
|
||||
verification := mergeDefaultsProjectionHelper + `
|
||||
const cases = [
|
||||
['reviewed PR title', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'PR_TITLE'}, 'reviewed'],
|
||||
['reviewed blank body', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'BLANK'}, 'reviewed'],
|
||||
['dual omission', {}, 'omitted'],
|
||||
['null repository', null, 'invalid'],
|
||||
['array repository', [], 'invalid'],
|
||||
['title only', {merge_commit_title: 'MERGE_MESSAGE'}, 'invalid'],
|
||||
['message only', {merge_commit_message: 'PR_TITLE'}, 'invalid'],
|
||||
['own undefined title', {merge_commit_title: undefined, merge_commit_message: 'PR_TITLE'}, 'invalid'],
|
||||
['own undefined message', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: undefined}, 'invalid'],
|
||||
['null title', {merge_commit_title: null, merge_commit_message: 'PR_TITLE'}, 'invalid'],
|
||||
['null message', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: null}, 'invalid'],
|
||||
['unsafe title', {merge_commit_title: 'PR_TITLE', merge_commit_message: 'PR_TITLE'}, 'invalid'],
|
||||
['unsafe body', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'PR_BODY'}, 'invalid'],
|
||||
];
|
||||
for (const [name, repository, want] of cases) {
|
||||
const got = classifyMergeDefaults(repository);
|
||||
if (got !== want) {
|
||||
throw new Error(name + ': got ' + got + ', want ' + want);
|
||||
}
|
||||
}
|
||||
`
|
||||
command := exec.Command(node, "-e", verification)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Fatalf("merge-default projection verification failed: %v\n%s", runErr, output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const strictUpdateRuleHelper = `function isStrictUpdateRule(rule) {
|
||||
if (rule?.type !== 'update') {
|
||||
return false;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
|
||||
return true;
|
||||
}
|
||||
const parameters = rule.parameters;
|
||||
if (
|
||||
parameters === null ||
|
||||
typeof parameters !== 'object' ||
|
||||
Array.isArray(parameters)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const parameterKeys = Object.keys(parameters);
|
||||
return (
|
||||
parameterKeys.length === 1 &&
|
||||
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
|
||||
parameters.update_allows_fetch_and_merge === false
|
||||
);
|
||||
}`
|
||||
|
||||
const strictGraphQLUpdateRuleHelper = `function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
|
||||
const restRulesetID = Number(restRuleset?.id);
|
||||
const graphRulesetID = Number(graphRuleset?.databaseId);
|
||||
const graphRules = graphRuleset?.rules;
|
||||
const graphRule = graphRules?.nodes?.[0];
|
||||
return (
|
||||
Number.isSafeInteger(restRulesetID) &&
|
||||
restRulesetID > 0 &&
|
||||
graphRulesetID === restRulesetID &&
|
||||
graphRuleset.name === restRuleset.name &&
|
||||
graphRuleset.enforcement === 'ACTIVE' &&
|
||||
graphRuleset.target === 'BRANCH' &&
|
||||
graphRules?.totalCount === 1 &&
|
||||
graphRules.nodes?.length === 1 &&
|
||||
graphRule?.type === 'UPDATE' &&
|
||||
graphRule.parameters?.__typename === 'UpdateParameters' &&
|
||||
graphRule.parameters.updateAllowsFetchAndMerge === false
|
||||
);
|
||||
}`
|
||||
|
||||
func TestReviewerRouterAcceptsGitHubStrictUpdateReadProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to verify the ruleset projection helper")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
|
||||
var scripts []string
|
||||
for _, relativePath := range []string{
|
||||
filepath.Join(".github", "workflows", "ci.yml"),
|
||||
filepath.Join(".github", "workflows", "reviewer-router.yml"),
|
||||
} {
|
||||
path := filepath.Join(root, relativePath)
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s: %v", path, readErr)
|
||||
}
|
||||
var workflow any
|
||||
if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil {
|
||||
t.Fatalf("parse %s: %v", path, unmarshalErr)
|
||||
}
|
||||
collectGitHubScripts(workflow, &scripts)
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for _, script := range scripts {
|
||||
if !strings.Contains(script, "const writerRuleset = writerRulesets[0];") {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if got := strings.Count(script, strictUpdateRuleHelper); got != 1 {
|
||||
t.Errorf("writer-ruleset script contains strict update helper %d times, want 1", got)
|
||||
}
|
||||
if got := strings.Count(script, strictGraphQLUpdateRuleHelper); got != 1 {
|
||||
t.Errorf("writer-ruleset script contains GraphQL strict update helper %d times, want 1", got)
|
||||
}
|
||||
if strings.Contains(
|
||||
script,
|
||||
"parameters?.update_allows_fetch_and_merge !== false",
|
||||
) {
|
||||
t.Error("writer-ruleset script rejects GitHub's omitted strict-false read projection")
|
||||
}
|
||||
if !strings.Contains(script, "!isStrictUpdateRule(writerRuleset.rules[0])") {
|
||||
t.Error("writer-ruleset script does not enforce the strict update helper")
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"query ReviewerRouterWriterRule($rulesetID: ID!)",
|
||||
"rules(first: 2)",
|
||||
"updateAllowsFetchAndMerge",
|
||||
"{rulesetID: writerRuleset.node_id}",
|
||||
"!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("writer-ruleset script does not enforce GraphQL marker %q", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 3 {
|
||||
t.Fatalf("writer-ruleset scripts checked = %d, want App enable, reconcile, and CI self-check", checked)
|
||||
}
|
||||
|
||||
verification := strictUpdateRuleHelper + "\n" + strictGraphQLUpdateRuleHelper + `
|
||||
const cases = [
|
||||
['omitted parameters', {type: 'update'}, true],
|
||||
['explicit false', {type: 'update', parameters: {update_allows_fetch_and_merge: false}}, true],
|
||||
['null parameters', {type: 'update', parameters: null}, false],
|
||||
['empty parameters', {type: 'update', parameters: {}}, false],
|
||||
['boolean parameters', {type: 'update', parameters: false}, false],
|
||||
['string parameters', {type: 'update', parameters: 'malformed'}, false],
|
||||
['array parameters', {type: 'update', parameters: []}, false],
|
||||
['explicit true', {type: 'update', parameters: {update_allows_fetch_and_merge: true}}, false],
|
||||
['null field', {type: 'update', parameters: {update_allows_fetch_and_merge: null}}, false],
|
||||
['string false', {type: 'update', parameters: {update_allows_fetch_and_merge: 'false'}}, false],
|
||||
['numeric false', {type: 'update', parameters: {update_allows_fetch_and_merge: 0}}, false],
|
||||
['extra parameter', {type: 'update', parameters: {update_allows_fetch_and_merge: false, future_exception: false}}, false],
|
||||
['wrong rule type', {type: 'creation'}, false],
|
||||
['null rule', null, false],
|
||||
];
|
||||
for (const [name, rule, want] of cases) {
|
||||
const got = isStrictUpdateRule(rule);
|
||||
if (got !== want) {
|
||||
throw new Error(name + ': got ' + got + ', want ' + want);
|
||||
}
|
||||
}
|
||||
|
||||
const restRuleset = {id: 21363804, name: 'main-merge-writers'};
|
||||
const graphRule = {
|
||||
type: 'UPDATE',
|
||||
parameters: {
|
||||
__typename: 'UpdateParameters',
|
||||
updateAllowsFetchAndMerge: false,
|
||||
},
|
||||
};
|
||||
const graphRuleset = {
|
||||
databaseId: 21363804,
|
||||
name: 'main-merge-writers',
|
||||
enforcement: 'ACTIVE',
|
||||
target: 'BRANCH',
|
||||
rules: {totalCount: 1, nodes: [graphRule]},
|
||||
};
|
||||
const graphCases = [
|
||||
['valid GraphQL projection', restRuleset, graphRuleset, true],
|
||||
['invalid REST id', {id: 0, name: 'main-merge-writers'}, graphRuleset, false],
|
||||
['mismatched GraphQL id', restRuleset, {...graphRuleset, databaseId: 7}, false],
|
||||
['mismatched name', restRuleset, {...graphRuleset, name: 'other'}, false],
|
||||
['inactive ruleset', restRuleset, {...graphRuleset, enforcement: 'EVALUATE'}, false],
|
||||
['wrong target', restRuleset, {...graphRuleset, target: 'TAG'}, false],
|
||||
['extra rule', restRuleset, {...graphRuleset, rules: {totalCount: 2, nodes: [graphRule, graphRule]}}, false],
|
||||
['wrong rule type', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, type: 'CREATION'}]}}, false],
|
||||
['wrong parameter type', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, parameters: {...graphRule.parameters, __typename: 'PullRequestParameters'}}]}}, false],
|
||||
['fetch and merge enabled', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, parameters: {...graphRule.parameters, updateAllowsFetchAndMerge: true}}]}}, false],
|
||||
['missing GraphQL node', restRuleset, null, false],
|
||||
];
|
||||
for (const [name, rest, graph, want] of graphCases) {
|
||||
const got = isStrictGraphQLUpdateRule(rest, graph);
|
||||
if (got !== want) {
|
||||
throw new Error(name + ': got ' + got + ', want ' + want);
|
||||
}
|
||||
}
|
||||
`
|
||||
command := exec.Command(node, "-e", verification)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Fatalf("strict update projection verification failed: %v\n%s", runErr, output)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
type reviewerRouterWorkflow struct {
|
||||
On map[string]reviewerRouterTrigger `yaml:"on"`
|
||||
Permissions map[string]string `yaml:"permissions"`
|
||||
Concurrency map[string]string `yaml:"concurrency"`
|
||||
Jobs map[string]reviewerRouterJob `yaml:"jobs"`
|
||||
}
|
||||
|
||||
@@ -25,12 +26,16 @@ type reviewerRouterTrigger struct {
|
||||
}
|
||||
|
||||
type reviewerRouterJob struct {
|
||||
If string `yaml:"if"`
|
||||
Steps []reviewerRouterStep `yaml:"steps"`
|
||||
If string `yaml:"if"`
|
||||
Permissions map[string]string `yaml:"permissions"`
|
||||
Steps []reviewerRouterStep `yaml:"steps"`
|
||||
}
|
||||
|
||||
type reviewerRouterStep struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Uses string `yaml:"uses"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
With map[string]string `yaml:"with"`
|
||||
}
|
||||
|
||||
@@ -68,8 +73,8 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(workflow.On) != 1 {
|
||||
t.Fatalf("workflow triggers = %v, want pull_request_target only", workflow.On)
|
||||
if len(workflow.On) != 3 {
|
||||
t.Fatalf("workflow triggers = %v, want pull_request_target plus protected-main reconciliation", workflow.On)
|
||||
}
|
||||
trigger, ok := workflow.On["pull_request_target"]
|
||||
if !ok {
|
||||
@@ -78,47 +83,108 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
|
||||
if wantBranches := []string{"main"}; !reflect.DeepEqual(trigger.Branches, wantBranches) {
|
||||
t.Fatalf("pull_request_target branches = %v, want %v", trigger.Branches, wantBranches)
|
||||
}
|
||||
wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review"}
|
||||
wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review", "edited", "auto_merge_enabled"}
|
||||
if !reflect.DeepEqual(trigger.Types, wantTypes) {
|
||||
t.Fatalf("pull_request_target types = %v, want %v", trigger.Types, wantTypes)
|
||||
}
|
||||
if _, ok := workflow.On["workflow_dispatch"]; !ok {
|
||||
t.Fatalf("workflow triggers = %v, want workflow_dispatch reconciliation", workflow.On)
|
||||
}
|
||||
push, ok := workflow.On["push"]
|
||||
if !ok || !reflect.DeepEqual(push.Branches, []string{"main"}) {
|
||||
t.Fatalf("push trigger = %v, want protected main only", push)
|
||||
}
|
||||
|
||||
wantPermissions := map[string]string{
|
||||
"contents": "write",
|
||||
"contents": "read",
|
||||
"pull-requests": "write",
|
||||
}
|
||||
if !reflect.DeepEqual(workflow.Permissions, wantPermissions) {
|
||||
t.Fatalf("workflow permissions = %v, want exactly %v", workflow.Permissions, wantPermissions)
|
||||
}
|
||||
wantConcurrency := map[string]string{
|
||||
"group": "reviewer-router-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || 'reconcile-main' }}",
|
||||
"cancel-in-progress": "${{ github.event_name == 'pull_request_target' }}",
|
||||
}
|
||||
if !reflect.DeepEqual(workflow.Concurrency, wantConcurrency) {
|
||||
t.Fatalf("workflow concurrency = %v, want serialized reconciliation and cancellable PR events %v", workflow.Concurrency, wantConcurrency)
|
||||
}
|
||||
|
||||
if len(workflow.Jobs) != 1 {
|
||||
t.Fatalf("workflow jobs = %v, want one isolated routing job", workflow.Jobs)
|
||||
if len(workflow.Jobs) != 3 {
|
||||
t.Fatalf("workflow jobs = %v, want event routing plus controlled reconciliation", workflow.Jobs)
|
||||
}
|
||||
job, ok := workflow.Jobs["route"]
|
||||
if !ok {
|
||||
t.Fatalf("workflow jobs = %v, want route", workflow.Jobs)
|
||||
}
|
||||
if job.If != "github.event.pull_request.draft == false" {
|
||||
if job.If != "github.event_name == 'pull_request_target' && github.event.pull_request.draft == false" {
|
||||
t.Fatalf("route.if = %q, want non-draft guard", job.If)
|
||||
}
|
||||
const checkoutSHA = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683"
|
||||
const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b"
|
||||
if len(job.Steps) != 2 || job.Steps[0].Uses != checkoutSHA || job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || job.Steps[0].With["persist-credentials"] != "false" || job.Steps[1].Uses != githubScriptSHA {
|
||||
t.Fatalf("route steps = %#v, want trusted base checkout followed by pinned github-script", job.Steps)
|
||||
const appTokenSHA = "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1"
|
||||
if len(job.Steps) != 2 ||
|
||||
job.Steps[0].Uses != checkoutSHA ||
|
||||
job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" ||
|
||||
job.Steps[0].With["persist-credentials"] != "false" ||
|
||||
job.Steps[1].Uses != githubScriptSHA {
|
||||
t.Fatalf("route steps = %#v, want trusted base checkout followed by routing only", job.Steps)
|
||||
}
|
||||
|
||||
script := job.Steps[1].With["script"]
|
||||
autoMergeJob, ok := workflow.Jobs["manage-auto-merge"]
|
||||
if !ok {
|
||||
t.Fatalf("workflow jobs = %v, want manage-auto-merge", workflow.Jobs)
|
||||
}
|
||||
if autoMergeJob.If != "github.event_name == 'pull_request_target' && github.event.pull_request.draft == false" {
|
||||
t.Fatalf("manage-auto-merge.if = %q, want exact ready event guard", autoMergeJob.If)
|
||||
}
|
||||
wantAutoPermissions := map[string]string{
|
||||
"contents": "write",
|
||||
"pull-requests": "write",
|
||||
}
|
||||
if !reflect.DeepEqual(autoMergeJob.Permissions, wantAutoPermissions) {
|
||||
t.Fatalf("manage-auto-merge permissions = %v, want exactly %v", autoMergeJob.Permissions, wantAutoPermissions)
|
||||
}
|
||||
if len(autoMergeJob.Steps) != 3 ||
|
||||
autoMergeJob.Steps[0].Uses != githubScriptSHA ||
|
||||
autoMergeJob.Steps[1].ID != "reviewer-router-token" ||
|
||||
autoMergeJob.Steps[1].Uses != appTokenSHA ||
|
||||
autoMergeJob.Steps[2].Uses != githubScriptSHA {
|
||||
t.Fatalf("manage-auto-merge steps = %#v, want unsafe-owner cleanup before dedicated App enable", autoMergeJob.Steps)
|
||||
}
|
||||
|
||||
appToken := autoMergeJob.Steps[1]
|
||||
if len(appToken.With) != 6 {
|
||||
t.Fatalf("reviewer-router token inputs = %v, want exactly the reviewed repository scope and two permissions", appToken.With)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"client-id": "${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }}",
|
||||
"private-key": "${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }}",
|
||||
"owner": "${{ github.repository_owner }}",
|
||||
"repositories": "${{ github.event.repository.name }}",
|
||||
"permission-contents": "write",
|
||||
"permission-pull-requests": "write",
|
||||
} {
|
||||
if got := appToken.With[key]; got != want {
|
||||
t.Errorf("reviewer-router token input %q = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := appToken.With["skip-token-revoke"]; ok {
|
||||
t.Error("reviewer-router App token must be revoked automatically after the job")
|
||||
}
|
||||
|
||||
routingScript := job.Steps[1].With["script"]
|
||||
for _, want := range []string{
|
||||
"REVIEWER_POOL",
|
||||
"resolveReviewRouting",
|
||||
"github.rest.pulls.get",
|
||||
"github.rest.pulls.listFiles",
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.base.sha !== eventBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"getReadyEventPull('review request')",
|
||||
"getReadyEventPull('auto-merge enable')",
|
||||
"context.payload.action === 'synchronize'",
|
||||
"context.payload.sender?.login?.toLowerCase()",
|
||||
"reviewer.toLowerCase() !== author",
|
||||
@@ -142,22 +208,441 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
|
||||
"github.rest.pulls.requestReviewers",
|
||||
"trying the next candidate",
|
||||
"Reviewer routing hit an unexpected error",
|
||||
"enablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
"core.warning",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
if !strings.Contains(routingScript, want) {
|
||||
t.Errorf("reviewer router script is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"enablePullRequestAutoMerge",
|
||||
"disablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
} {
|
||||
if strings.Contains(routingScript, forbidden) {
|
||||
t.Errorf("built-in routing token must not control auto-merge through %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupStep := autoMergeJob.Steps[0]
|
||||
if _, ok := cleanupStep.With["github-token"]; ok {
|
||||
t.Error("unsafe-owner cleanup must use only the job-scoped built-in token")
|
||||
}
|
||||
cleanupScript := cleanupStep.With["script"]
|
||||
for _, want := range []string{
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.base.sha !== eventBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"const unsafeOwner = 'github-actions[bot]'",
|
||||
"const skipWorkflowPattern =",
|
||||
"currentPull.title",
|
||||
"currentPull.auto_merge?.commit_title",
|
||||
"currentPull.auto_merge?.commit_message",
|
||||
"const skipRequested =",
|
||||
"enabledBy !== unsafeOwner",
|
||||
"disablePullRequestAutoMerge",
|
||||
"cleanedPull.auto_merge",
|
||||
"Cleared workflow-skipping auto-merge metadata",
|
||||
"throw new Error",
|
||||
} {
|
||||
if !strings.Contains(cleanupScript, want) {
|
||||
t.Errorf("unsafe-owner cleanup is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(cleanupScript, "enablePullRequestAutoMerge") {
|
||||
t.Error("built-in cleanup token must never enable auto-merge")
|
||||
}
|
||||
if got := strings.Count(cleanupScript, "const skipWorkflowPattern ="); got != 1 {
|
||||
t.Errorf("unsafe-request cleanup skip-workflow pattern declarations = %d, want exactly 1", got)
|
||||
}
|
||||
|
||||
autoMergeStep := autoMergeJob.Steps[2]
|
||||
if got, want := autoMergeStep.With["github-token"], "${{ steps.reviewer-router-token.outputs.token }}"; got != want {
|
||||
t.Fatalf("auto-merge github-token = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := autoMergeStep.Env["MINTED_REVIEWER_ROUTER_APP_SLUG"], "${{ steps.reviewer-router-token.outputs.app-slug }}"; got != want {
|
||||
t.Fatalf("minted auto-merge App slug = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := autoMergeStep.Env["REVIEWER_ROUTER_APP_SLUG"], "${{ vars.REVIEWER_ROUTER_APP_SLUG }}"; got != want {
|
||||
t.Fatalf("configured auto-merge App slug = %q, want %q", got, want)
|
||||
}
|
||||
autoMergeScript := autoMergeStep.With["script"]
|
||||
for _, want := range []string{
|
||||
"github.rest.pulls.get",
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.base.sha !== eventBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"getReadyEventPull('auto-merge enable')",
|
||||
"process.env.MINTED_REVIEWER_ROUTER_APP_SLUG?.trim()",
|
||||
"process.env.REVIEWER_ROUTER_APP_SLUG?.trim()",
|
||||
"appSlug === 'github-actions'",
|
||||
"mintedAppSlug !== appSlug",
|
||||
"const expectedAppOwner = `${appSlug}[bot]`",
|
||||
"GET /repos/{owner}/{repo}/rules/branches/{branch}",
|
||||
".map(rule => Number(rule.ruleset_id))",
|
||||
"Number.isSafeInteger(rulesetID) && rulesetID > 0",
|
||||
"GET /repos/{owner}/{repo}/rulesets/{ruleset_id}",
|
||||
"ruleset.enforcement !== 'active'",
|
||||
"ruleset.target !== 'branch'",
|
||||
"ruleset.name === 'main-merge-writers'",
|
||||
"writerRuleset.source_type !== 'Repository'",
|
||||
"writerRuleset.source?.toLowerCase() !== repositorySource",
|
||||
"writerIncludes[0] !== 'refs/heads/main'",
|
||||
"writerExcludes.length !== 0",
|
||||
"writerRuleset.rules?.length !== 1",
|
||||
"function isStrictUpdateRule(rule)",
|
||||
"rule?.type !== 'update'",
|
||||
"!Object.prototype.hasOwnProperty.call(rule, 'parameters')",
|
||||
"parameters === null",
|
||||
"typeof parameters !== 'object'",
|
||||
"Array.isArray(parameters)",
|
||||
"const parameterKeys = Object.keys(parameters)",
|
||||
"parameterKeys.length === 1",
|
||||
"parameterKeys[0] === 'update_allows_fetch_and_merge'",
|
||||
"parameters.update_allows_fetch_and_merge === false",
|
||||
"!isStrictUpdateRule(writerRuleset.rules[0])",
|
||||
"typeof writerRuleset.node_id !== 'string'",
|
||||
"function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)",
|
||||
"graphRule.parameters.updateAllowsFetchAndMerge === false",
|
||||
"query ReviewerRouterWriterRule($rulesetID: ID!)",
|
||||
"... on UpdateParameters",
|
||||
"{rulesetID: writerRuleset.node_id}",
|
||||
"!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)",
|
||||
"current_user_can_bypass !== 'pull_requests_only'",
|
||||
"ruleset.current_user_can_bypass !== 'never'",
|
||||
"Reviewer Router App must never bypass main ruleset",
|
||||
"const skipWorkflowPattern =",
|
||||
"const safeCommitHeadline = `Merge pull request #${pullNumber}`",
|
||||
"const safeCommitBody =",
|
||||
"workflow-skip cleanup",
|
||||
"already has the reviewed ${expectedAppOwner} auto-merge request",
|
||||
"preserving it",
|
||||
"non-dedicated owner cleanup",
|
||||
"unsafe dedicated-App metadata cleanup",
|
||||
"getReadyEventPull('post-owner cleanup')",
|
||||
"enablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
"expectedHeadOid: $expectedHeadOid",
|
||||
"commitHeadline: $commitHeadline",
|
||||
"commitBody: $commitBody",
|
||||
"expectedHeadOid: eventHeadSha",
|
||||
"const exactRevision =",
|
||||
"enabledBy === expectedAppOwner",
|
||||
"disablePullRequestAutoMerge",
|
||||
"unsafe-merge-message cleanup",
|
||||
"enabledPull.auto_merge?.commit_title !== safeCommitHeadline",
|
||||
"enabledPull.auto_merge?.commit_message !== safeCommitBody",
|
||||
"if (revertedPull.auto_merge)",
|
||||
"enabledBy !== expectedAppOwner",
|
||||
} {
|
||||
if !strings.Contains(autoMergeScript, want) {
|
||||
t.Errorf("dedicated auto-merge script is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(autoMergeScript, "const skipWorkflowPattern ="); got != 1 {
|
||||
t.Errorf("dedicated auto-merge script skip-workflow pattern declarations = %d, want exactly 1", got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"if (!exactRevision && enabledBy === expectedAppOwner)",
|
||||
"disableAutoMerge(enabledPull, 'stale-enable cleanup')",
|
||||
"existingOwner === expectedAppOwner &&",
|
||||
"disableAutoMerge(currentPull, 'workflow-skip cleanup')",
|
||||
"currentPull.auto_merge.commit_title === safeCommitHeadline",
|
||||
"currentPull.auto_merge.commit_message === safeCommitBody",
|
||||
"disableAutoMerge(enabledPull, 'unexpected owner cleanup')",
|
||||
} {
|
||||
if !strings.Contains(autoMergeScript, want) {
|
||||
t.Errorf("dedicated App stale-state cleanup is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"core.warning", "catch ("} {
|
||||
if strings.Contains(autoMergeScript, forbidden) {
|
||||
t.Errorf("dedicated auto-merge must fail closed instead of handling errors through %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"rule.ruleset_source_type",
|
||||
"rule.ruleset_source?.toLowerCase()",
|
||||
"ruleset.source_type !== 'Repository'",
|
||||
"ruleset.source?.toLowerCase() !== repositorySource",
|
||||
} {
|
||||
if strings.Contains(autoMergeScript, forbidden) {
|
||||
t.Errorf("dedicated App must inspect every applicable main ruleset, not prefilter through %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
reconcile, ok := workflow.Jobs["reconcile"]
|
||||
if !ok {
|
||||
t.Fatalf("workflow jobs = %v, want reconcile", workflow.Jobs)
|
||||
}
|
||||
if reconcile.If != "(github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'" {
|
||||
t.Fatalf("reconcile.if = %q, want exact protected-main manual guard", reconcile.If)
|
||||
}
|
||||
if len(reconcile.Steps) != 2 ||
|
||||
reconcile.Steps[0].ID != "reviewer-router-reconcile-token" ||
|
||||
reconcile.Steps[0].Uses != appTokenSHA ||
|
||||
reconcile.Steps[1].Uses != githubScriptSHA {
|
||||
t.Fatalf("reconcile steps = %#v, want scoped App token followed by pinned migration script", reconcile.Steps)
|
||||
}
|
||||
if len(reconcile.Permissions) != 0 {
|
||||
t.Fatalf("reconcile built-in token permissions = %v, want none", reconcile.Permissions)
|
||||
}
|
||||
if len(reconcile.Steps[0].With) != 6 {
|
||||
t.Fatalf("reconcile token inputs = %v, want exactly the reviewed repository scope and two permissions", reconcile.Steps[0].With)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"client-id": "${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }}",
|
||||
"private-key": "${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }}",
|
||||
"owner": "${{ github.repository_owner }}",
|
||||
"repositories": "${{ github.event.repository.name }}",
|
||||
"permission-contents": "write",
|
||||
"permission-pull-requests": "write",
|
||||
} {
|
||||
if got := reconcile.Steps[0].With[key]; got != want {
|
||||
t.Errorf("reconcile token input %q = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := reconcile.Steps[0].With["skip-token-revoke"]; ok {
|
||||
t.Error("reconcile App token must be revoked automatically after the job")
|
||||
}
|
||||
if got, want := reconcile.Steps[1].With["github-token"], "${{ steps.reviewer-router-reconcile-token.outputs.token }}"; got != want {
|
||||
t.Fatalf("reconcile github-token = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := reconcile.Steps[1].Env["MINTED_REVIEWER_ROUTER_APP_SLUG"], "${{ steps.reviewer-router-reconcile-token.outputs.app-slug }}"; got != want {
|
||||
t.Fatalf("minted reconcile App slug = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := reconcile.Steps[1].Env["REVIEWER_ROUTER_APP_SLUG"], "${{ vars.REVIEWER_ROUTER_APP_SLUG }}"; got != want {
|
||||
t.Fatalf("configured reconcile App slug = %q, want %q", got, want)
|
||||
}
|
||||
reconcileScript := reconcile.Steps[1].With["script"]
|
||||
for _, want := range []string{
|
||||
"github.paginate(github.rest.pulls.list",
|
||||
"state: 'open'",
|
||||
"base: 'main'",
|
||||
"candidate.draft",
|
||||
"const candidateOwner =",
|
||||
"const candidateSkipRequested =",
|
||||
"const candidateHasSafeAppRequest =",
|
||||
"candidateOwner === expectedAppOwner",
|
||||
"!candidate.auto_merge",
|
||||
"candidateHasSafeAppRequest",
|
||||
"currentPull.head.sha !== expectedHeadSha",
|
||||
"currentPull.base.sha !== expectedBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"const currentHasSafeAppRequest =",
|
||||
"currentOwner === expectedAppOwner",
|
||||
"currentHasSafeAppRequest",
|
||||
"async function disableAutoMerge(pullNumber, pullRequestId, phase)",
|
||||
"disablePullRequestAutoMerge",
|
||||
"if (revertedPull.auto_merge)",
|
||||
"return revertedPull",
|
||||
"replace non-dedicated auto-merge owner",
|
||||
"repair dedicated-App merge metadata",
|
||||
"enablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
"const expectedAppOwner = `${appSlug}[bot]`",
|
||||
"GET /repos/{owner}/{repo}/rules/branches/{branch}",
|
||||
".map(rule => Number(rule.ruleset_id))",
|
||||
"Number.isSafeInteger(rulesetID) && rulesetID > 0",
|
||||
"GET /repos/{owner}/{repo}/rulesets/{ruleset_id}",
|
||||
"ruleset.enforcement !== 'active'",
|
||||
"ruleset.target !== 'branch'",
|
||||
"ruleset.name === 'main-merge-writers'",
|
||||
"writerRuleset.source_type !== 'Repository'",
|
||||
"writerRuleset.source?.toLowerCase() !== repositorySource",
|
||||
"writerIncludes[0] !== 'refs/heads/main'",
|
||||
"writerExcludes.length !== 0",
|
||||
"writerRuleset.rules?.length !== 1",
|
||||
"function isStrictUpdateRule(rule)",
|
||||
"rule?.type !== 'update'",
|
||||
"!Object.prototype.hasOwnProperty.call(rule, 'parameters')",
|
||||
"parameters === null",
|
||||
"typeof parameters !== 'object'",
|
||||
"Array.isArray(parameters)",
|
||||
"const parameterKeys = Object.keys(parameters)",
|
||||
"parameterKeys.length === 1",
|
||||
"parameterKeys[0] === 'update_allows_fetch_and_merge'",
|
||||
"parameters.update_allows_fetch_and_merge === false",
|
||||
"!isStrictUpdateRule(writerRuleset.rules[0])",
|
||||
"typeof writerRuleset.node_id !== 'string'",
|
||||
"function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)",
|
||||
"graphRule.parameters.updateAllowsFetchAndMerge === false",
|
||||
"query ReviewerRouterWriterRule($rulesetID: ID!)",
|
||||
"... on UpdateParameters",
|
||||
"{rulesetID: writerRuleset.node_id}",
|
||||
"!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)",
|
||||
"current_user_can_bypass !== 'pull_requests_only'",
|
||||
"ruleset.current_user_can_bypass !== 'never'",
|
||||
"Reviewer Router App must never bypass main ruleset",
|
||||
"const skipWorkflowPattern =",
|
||||
"const skipRequested =",
|
||||
"workflow-skip directive found in merge metadata; left manual-merge only",
|
||||
"const safeCommitHeadline = `Merge pull request #${candidate.number}`",
|
||||
"const safeCommitBody =",
|
||||
"expectedHeadOid: $expectedHeadOid",
|
||||
"commitHeadline: $commitHeadline",
|
||||
"commitBody: $commitBody",
|
||||
"expectedHeadOid: expectedHeadSha",
|
||||
"enabledPull.auto_merge?.commit_title !== safeCommitHeadline",
|
||||
"enabledPull.auto_merge?.commit_message !== safeCommitBody",
|
||||
"const failures = []",
|
||||
"catch (error)",
|
||||
"failures.push(failure)",
|
||||
"core.setFailed",
|
||||
"revisionChanged && enabledBy === expectedAppOwner",
|
||||
"enabledBy !== expectedAppOwner",
|
||||
"unexpected owner cleanup",
|
||||
"unsafe merge-message cleanup",
|
||||
} {
|
||||
if !strings.Contains(reconcileScript, want) {
|
||||
t.Errorf("reconciliation script is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
if disableIndex, enableIndex := strings.Index(reconcileScript, "disablePullRequestAutoMerge"), strings.Index(reconcileScript, "enablePullRequestAutoMerge"); disableIndex < 0 || enableIndex <= disableIndex {
|
||||
t.Error("legacy reconciliation must disable the exact unsafe owner before App-token enable")
|
||||
}
|
||||
if got := strings.Count(reconcileScript, "const skipWorkflowPattern ="); got != 1 {
|
||||
t.Errorf("reconciliation script skip-workflow pattern declarations = %d, want exactly 1", got)
|
||||
}
|
||||
if strings.Contains(reconcileScript, "const unsafeOwner") {
|
||||
t.Error("reconciliation must converge every non-App owner, not only the legacy built-in owner")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"rule.ruleset_source_type",
|
||||
"rule.ruleset_source?.toLowerCase()",
|
||||
"ruleset.source_type !== 'Repository'",
|
||||
"ruleset.source?.toLowerCase() !== repositorySource",
|
||||
} {
|
||||
if strings.Contains(reconcileScript, forbidden) {
|
||||
t.Errorf("reconciliation App must inspect every applicable main ruleset, not prefilter through %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forbidden := range []string{
|
||||
"['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']",
|
||||
"PeterGuy326",
|
||||
"core.setFailed",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"secrets.RELEASE_GOVERNANCE_TOKEN",
|
||||
"secrets.GITHUB_TOKEN",
|
||||
} {
|
||||
if strings.Contains(string(data), forbidden) {
|
||||
t.Errorf("reviewer router must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeAdmissionEnforcesReviewerRouterWriterBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ci.yml) error = %v", err)
|
||||
}
|
||||
workflow := string(data)
|
||||
if !strings.Contains(workflow, "pull_request:\n types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]") {
|
||||
t.Error("CI must rerun admission when a draft becomes ready or merge metadata changes")
|
||||
}
|
||||
start := strings.Index(workflow, "\n test:\n")
|
||||
end := strings.Index(workflow, "\n test-darwin:\n")
|
||||
if start < 0 || end <= start {
|
||||
t.Fatal("CI workflow missing Test aggregate boundaries")
|
||||
}
|
||||
testJob := workflow[start:end]
|
||||
for _, want := range []string{
|
||||
"contents: read",
|
||||
"pull-requests: read",
|
||||
"name: Verify auto-merge identity",
|
||||
"if: github.event_name == 'pull_request' && github.event.pull_request.draft == false",
|
||||
"REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}",
|
||||
"process.env.REVIEWER_ROUTER_APP_SLUG?.trim()",
|
||||
"const expectedAppOwner = `${appSlug}[bot]`",
|
||||
"github.rest.pulls.get",
|
||||
"github.rest.repos.get",
|
||||
"function classifyMergeDefaults(repository)",
|
||||
"mergeDefaultsProjection === 'invalid'",
|
||||
"mergeDefaultsProjection === 'omitted'",
|
||||
"exact validation is delegated to the dedicated App",
|
||||
"const writerRulesetName = 'main-merge-writers'",
|
||||
"GET /repos/{owner}/{repo}/rules/branches/{branch}",
|
||||
"rule.ruleset_source_type === 'Repository'",
|
||||
"rule.ruleset_source?.toLowerCase() === repositorySource",
|
||||
"GET /repos/{owner}/{repo}/rulesets/{ruleset_id}",
|
||||
"ruleset.enforcement !== 'active'",
|
||||
"ruleset.target !== 'branch'",
|
||||
"ruleset.source_type !== 'Repository'",
|
||||
"ruleset.source?.toLowerCase() !== repositorySource",
|
||||
"writerIncludes[0] !== 'refs/heads/main'",
|
||||
"writerExcludes.length !== 0",
|
||||
"writerRuleset.rules?.length !== 1",
|
||||
"function isStrictUpdateRule(rule)",
|
||||
"rule?.type !== 'update'",
|
||||
"!Object.prototype.hasOwnProperty.call(rule, 'parameters')",
|
||||
"parameters === null",
|
||||
"typeof parameters !== 'object'",
|
||||
"Array.isArray(parameters)",
|
||||
"const parameterKeys = Object.keys(parameters)",
|
||||
"parameterKeys.length === 1",
|
||||
"parameterKeys[0] === 'update_allows_fetch_and_merge'",
|
||||
"parameters.update_allows_fetch_and_merge === false",
|
||||
"!isStrictUpdateRule(writerRuleset.rules[0])",
|
||||
"typeof writerRuleset.node_id !== 'string'",
|
||||
"function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)",
|
||||
"graphRule.parameters.updateAllowsFetchAndMerge === false",
|
||||
"query ReviewerRouterWriterRule($rulesetID: ID!)",
|
||||
"... on UpdateParameters",
|
||||
"{rulesetID: writerRuleset.node_id}",
|
||||
"!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)",
|
||||
"writerRuleset.current_user_can_bypass !== 'never'",
|
||||
"deny this built-in Actions identity any bypass",
|
||||
"const skipWorkflowPattern =",
|
||||
"currentPull.title",
|
||||
"currentPull.auto_merge?.commit_title",
|
||||
"currentPull.auto_merge?.commit_message",
|
||||
"merge metadata contains a GitHub workflow-skip directive",
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.base.sha !== eventBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"currentPull.auto_merge",
|
||||
"enabled_by?.login",
|
||||
"enabledBy === expectedAppOwner",
|
||||
"currentPull.auto_merge.commit_title === safeCommitHeadline",
|
||||
"currentPull.auto_merge.commit_message === safeCommitBody",
|
||||
"must be null or owned by ${expectedAppOwner}",
|
||||
"const maxAttempts = 6",
|
||||
"setTimeout(resolve, 5000)",
|
||||
"core.setFailed",
|
||||
} {
|
||||
if !strings.Contains(testJob, want) {
|
||||
t.Errorf("Test aggregate missing auto-merge identity contract %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(testJob, "Null and non-built-in merge identities emit either the protected-main") ||
|
||||
!strings.Contains(testJob, "main-merge-writers never lets it update main") {
|
||||
t.Error("auto-merge identity comment must name the push/repair paths and built-in writer self-check")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"REVIEWER_ROUTER_APP_PRIVATE_KEY",
|
||||
"HOMEBREW_PR_TOKEN",
|
||||
"RELEASE_GOVERNANCE_TOKEN",
|
||||
"can emit protected-main workflow events",
|
||||
"pull-requests: write",
|
||||
"contents: write",
|
||||
"actions: write",
|
||||
} {
|
||||
if strings.Contains(testJob, forbidden) {
|
||||
t.Errorf("read-only admission identity check must not consume %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user