Merge remote-tracking branch 'origin/main' into codex/todo-skill-optimization-run1
This commit is contained in:
commit
d3efec96ce
273 files changed
+34034
-3145
No files matched your search
@@ -351,6 +351,79 @@ This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable.
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyAcceptsOnlyUntaggedCanonicalBetaAmendments(t *testing.T) {
|
||||
newAmendmentRepo := func(t *testing.T) (*changelogGateRepo, string) {
|
||||
t.Helper()
|
||||
repo := newChangelogGateRepo(t)
|
||||
sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1")
|
||||
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected initial beta seal: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
changelogGateWrite(t, repo.root, ".changes/1235-sheet.md", "---\ncategory: Added\n---\n\n- Sheet accepts local float image files.\n", 0o644)
|
||||
repo.commit(t, "merge post-seal beta fragment")
|
||||
return repo, strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
}
|
||||
|
||||
stageCanonicalAmendment := func(t *testing.T, repo *changelogGateRepo) {
|
||||
t.Helper()
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- Chat reply mentions.
|
||||
|
||||
- Sheet accepts local float image files.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`, 0o644)
|
||||
archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1")
|
||||
if err := os.Rename(filepath.Join(repo.root, ".changes", "1235-sheet.md"), filepath.Join(archiveDir, "1235-sheet.md")); err != nil {
|
||||
t.Fatalf("Rename beta amendment fragment: %v", err)
|
||||
}
|
||||
repo.commit(t, "amend untagged beta release notes")
|
||||
}
|
||||
|
||||
t.Run("accepts exact pre-tag merge", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
|
||||
if output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected canonical beta amendment: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects tagged beta", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
changelogGateGit(t, repo.root, "tag", "v1.0.1-beta.1")
|
||||
|
||||
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "forbidden after tag v1.0.1-beta.1 exists") {
|
||||
t.Fatalf("tagged beta amendment passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects rewritten sealed prose", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", strings.Replace(changelogGateSealedRelease, "Chat reply mentions.", "Rewritten sealed note.", 1), 0o644)
|
||||
repo.commit(t, "rewrite sealed beta prose")
|
||||
|
||||
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "does not exactly match") {
|
||||
t.Fatalf("rewritten beta amendment passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
|
||||
t.Run("invalid active fragment", func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
@@ -1106,6 +1179,9 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
t.Fatal("Code Admission workflow missing Policy job boundaries")
|
||||
}
|
||||
policyJob := admission[policyStart:policyEnd]
|
||||
if !strings.Contains(policyJob, "timeout-minutes: 15") {
|
||||
t.Error("Policy job must retain enough headroom for full Schema policy validation")
|
||||
}
|
||||
requirePolicyEnv := func(step, nextStep string) {
|
||||
t.Helper()
|
||||
start := strings.Index(policyJob, " - name: "+step+"\n")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,22 @@ package scripts_test
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var expectedPackagedSkillTargets = []string{
|
||||
@@ -93,6 +98,350 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNPMWrapperForwardsSIGTERMToVendor(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX signal forwarding contract")
|
||||
}
|
||||
nodePath, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to exercise the npm wrapper")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
binDir := filepath.Join(root, "bin")
|
||||
vendorDir := filepath.Join(root, "vendor")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wrapperSource := filepath.Join("..", "..", "build", "npm", "bin", "dws.js")
|
||||
wrapperData, err := os.ReadFile(wrapperSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrapperPath := filepath.Join(binDir, "dws.js")
|
||||
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pidPath := filepath.Join(root, "child.pid")
|
||||
signalPath := filepath.Join(root, "child.signal")
|
||||
vendorPath := filepath.Join(vendorDir, "dws")
|
||||
vendorScript := "#!/bin/sh\n" +
|
||||
"printf '%s' \"$$\" > \"$DWS_TEST_CHILD_PID_FILE\"\n" +
|
||||
"trap 'printf TERM > \"$DWS_TEST_SIGNAL_FILE\"; exit 0' TERM\n" +
|
||||
"while :; do sleep 0.1; done\n"
|
||||
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(nodePath, wrapperPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DWS_TEST_CHILD_PID_FILE="+pidPath,
|
||||
"DWS_TEST_SIGNAL_FILE="+signalPath,
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
childPID := waitForPIDFile(t, pidPath)
|
||||
t.Cleanup(func() {
|
||||
_ = exec.Command("kill", "-TERM", strconv.Itoa(childPID)).Run()
|
||||
})
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal npm wrapper: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("signal-terminated npm wrapper returned success")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("npm wrapper did not exit after SIGTERM")
|
||||
}
|
||||
|
||||
signalData, err := os.ReadFile(signalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("vendor did not record SIGTERM: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(signalData)); got != "TERM" {
|
||||
t.Fatalf("vendor signal = %q, want TERM", got)
|
||||
}
|
||||
if err := exec.Command("kill", "-0", strconv.Itoa(childPID)).Run(); err == nil {
|
||||
t.Fatalf("vendor process %d is still running after npm wrapper exited", childPID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNPMWrapperForwardsForegroundGroupSIGINTToVendorGroupOnce(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX foreground process-group signal contract")
|
||||
}
|
||||
nodePath, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to exercise the npm wrapper")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
binDir := filepath.Join(root, "bin")
|
||||
vendorDir := filepath.Join(root, "vendor")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wrapperData, err := os.ReadFile(filepath.Join("..", "..", "build", "npm", "bin", "dws.js"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrapperPath := filepath.Join(binDir, "dws.js")
|
||||
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pidPath := filepath.Join(root, "child.pid")
|
||||
signalPath := filepath.Join(root, "child.signal")
|
||||
descendantPIDPath := filepath.Join(root, "descendant.pid")
|
||||
descendantSignalPath := filepath.Join(root, "descendant.signal")
|
||||
vendorPath := filepath.Join(vendorDir, "dws")
|
||||
vendorScript := `#!/usr/bin/env node
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const childProcess = require("child_process");
|
||||
const descendantSource = String.raw` + "`" + `
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
let exiting = false;
|
||||
process.on("SIGINT", () => {
|
||||
fs.appendFileSync(process.env.DWS_TEST_DESCENDANT_SIGNAL_FILE, "INT\n");
|
||||
if (!exiting) {
|
||||
exiting = true;
|
||||
setTimeout(() => process.exit(0), 250);
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(process.env.DWS_TEST_DESCENDANT_PID_FILE, String(process.pid));
|
||||
setInterval(() => {}, 1000);
|
||||
` + "`" + `;
|
||||
childProcess.spawn(process.execPath, ["-e", descendantSource], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
let exiting = false;
|
||||
process.on("SIGINT", () => {
|
||||
fs.appendFileSync(process.env.DWS_TEST_SIGNAL_FILE, "INT\n");
|
||||
if (!exiting) {
|
||||
exiting = true;
|
||||
setTimeout(() => process.exit(0), 250);
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(process.env.DWS_TEST_CHILD_PID_FILE, String(process.pid));
|
||||
setInterval(() => {}, 1000);
|
||||
`
|
||||
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(nodePath, wrapperPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DWS_TEST_CHILD_PID_FILE="+pidPath,
|
||||
"DWS_TEST_SIGNAL_FILE="+signalPath,
|
||||
"DWS_TEST_DESCENDANT_PID_FILE="+descendantPIDPath,
|
||||
"DWS_TEST_DESCENDANT_SIGNAL_FILE="+descendantSignalPath,
|
||||
)
|
||||
configureIsolatedProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
childPID := waitForPIDFile(t, pidPath)
|
||||
descendantPID := waitForPIDFile(t, descendantPIDPath)
|
||||
t.Cleanup(func() {
|
||||
_ = exec.Command("kill", "-TERM", strconv.Itoa(childPID)).Run()
|
||||
_ = exec.Command("kill", "-TERM", strconv.Itoa(descendantPID)).Run()
|
||||
})
|
||||
wrapperGroup, err := processGroupID(cmd.Process.Pid)
|
||||
if err != nil {
|
||||
t.Fatalf("wrapper process group: %v", err)
|
||||
}
|
||||
childGroup, err := processGroupID(childPID)
|
||||
if err != nil {
|
||||
t.Fatalf("vendor process group: %v", err)
|
||||
}
|
||||
if childGroup == wrapperGroup {
|
||||
t.Fatalf("vendor process group = wrapper process group %d; foreground signal would be duplicated", childGroup)
|
||||
}
|
||||
descendantGroup, err := processGroupID(descendantPID)
|
||||
if err != nil {
|
||||
t.Fatalf("descendant process group: %v", err)
|
||||
}
|
||||
if descendantGroup != childGroup {
|
||||
t.Fatalf("descendant process group = %d, want vendor process group %d", descendantGroup, childGroup)
|
||||
}
|
||||
|
||||
if err := signalProcessGroup(wrapperGroup, syscall.SIGINT); err != nil {
|
||||
t.Fatalf("signal foreground process group: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("signal-terminated npm wrapper returned success")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("npm wrapper did not exit after foreground SIGINT")
|
||||
}
|
||||
|
||||
signalData, err := os.ReadFile(signalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("vendor did not record SIGINT: %v", err)
|
||||
}
|
||||
if got := strings.Fields(string(signalData)); len(got) != 1 || got[0] != "INT" {
|
||||
t.Fatalf("vendor signals = %q, want exactly one INT", got)
|
||||
}
|
||||
descendantSignalData, err := os.ReadFile(descendantSignalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("vendor descendant did not record SIGINT: %v", err)
|
||||
}
|
||||
if got := strings.Fields(string(descendantSignalData)); len(got) != 1 || got[0] != "INT" {
|
||||
t.Fatalf("vendor descendant signals = %q, want exactly one INT", got)
|
||||
}
|
||||
waitForProcessExit(t, childPID, "vendor")
|
||||
waitForProcessExit(t, descendantPID, "vendor descendant")
|
||||
}
|
||||
|
||||
func TestNPMWrapperPreservesInteractiveTTY(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX controlling-terminal contract")
|
||||
}
|
||||
nodePath, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to exercise the npm wrapper")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
binDir := filepath.Join(root, "bin")
|
||||
vendorDir := filepath.Join(root, "vendor")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wrapperData, err := os.ReadFile(filepath.Join("..", "..", "build", "npm", "bin", "dws.js"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrapperPath := filepath.Join(binDir, "dws.js")
|
||||
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resultPath := filepath.Join(root, "interactive.result")
|
||||
readyPath := filepath.Join(root, "interactive.ready")
|
||||
vendorPath := filepath.Join(vendorDir, "dws")
|
||||
vendorScript := "#!/bin/sh\n" +
|
||||
"printf 'DWS prompt: ' > /dev/tty\n" +
|
||||
"printf ready > \"$DWS_TEST_INTERACTIVE_READY\"\n" +
|
||||
"IFS= read -r answer < /dev/tty\n" +
|
||||
"printf '%s' \"$answer\" > \"$DWS_TEST_INTERACTIVE_RESULT\"\n"
|
||||
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(nodePath, wrapperPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DWS_TEST_INTERACTIVE_RESULT="+resultPath,
|
||||
"DWS_TEST_INTERACTIVE_READY="+readyPath,
|
||||
)
|
||||
terminal, err := startWithPTY(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("start npm wrapper with PTY: %v", err)
|
||||
}
|
||||
defer terminal.Close()
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||
var output bytes.Buffer
|
||||
outputDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(&output, terminal)
|
||||
close(outputDone)
|
||||
}()
|
||||
waitForFile(t, readyPath)
|
||||
if _, err := fmt.Fprintln(terminal, "confirmed"); err != nil {
|
||||
t.Fatalf("write interactive input: %v", err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
var waitErr error
|
||||
select {
|
||||
case waitErr = <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("interactive npm wrapper did not exit after terminal input")
|
||||
}
|
||||
_ = terminal.Close()
|
||||
<-outputDone
|
||||
if waitErr != nil {
|
||||
t.Fatalf("interactive npm wrapper: %v\noutput:\n%s", waitErr, output.String())
|
||||
}
|
||||
result, err := os.ReadFile(resultPath)
|
||||
if err != nil {
|
||||
t.Fatalf("interactive vendor did not record terminal input: %v\noutput:\n%s", err, output.String())
|
||||
}
|
||||
if got := strings.TrimSpace(string(result)); got != "confirmed" {
|
||||
t.Fatalf("interactive input = %q, want confirmed\noutput:\n%s", got, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPIDFile(t *testing.T, path string) int {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if parseErr == nil && pid > 0 {
|
||||
return pid
|
||||
}
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for vendor pid file %s", path)
|
||||
return 0
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for file %s", path)
|
||||
}
|
||||
|
||||
func waitForProcessExit(t *testing.T, pid int, label string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := exec.Command("kill", "-0", strconv.Itoa(pid)).Run(); err != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("%s process %d is still running after npm wrapper exited", label, pid)
|
||||
}
|
||||
|
||||
func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2082,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",
|
||||
@@ -2091,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`,
|
||||
@@ -2098,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",
|
||||
@@ -2438,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 }}`,
|
||||
} {
|
||||
@@ -2481,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)
|
||||
@@ -2492,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)
|
||||
@@ -2505,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",
|
||||
@@ -2518,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,27 @@
|
||||
//go:build !windows
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func configureIsolatedProcessGroup(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
func processGroupID(pid int) (int, error) {
|
||||
return syscall.Getpgid(pid)
|
||||
}
|
||||
|
||||
func signalProcessGroup(groupID int, signal syscall.Signal) error {
|
||||
return syscall.Kill(-groupID, signal)
|
||||
}
|
||||
|
||||
func startWithPTY(cmd *exec.Cmd) (*os.File, error) {
|
||||
return pty.Start(cmd)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build windows
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureIsolatedProcessGroup(*exec.Cmd) {}
|
||||
|
||||
func processGroupID(int) (int, error) {
|
||||
return 0, errors.New("POSIX process groups are unavailable on Windows")
|
||||
}
|
||||
|
||||
func signalProcessGroup(int, syscall.Signal) error {
|
||||
return errors.New("POSIX process groups are unavailable on Windows")
|
||||
}
|
||||
|
||||
func startWithPTY(*exec.Cmd) (*os.File, error) {
|
||||
return nil, errors.New("POSIX pseudo terminals are unavailable on Windows")
|
||||
}
|
||||
@@ -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