Repository navigation
Expand file tree
/
Copy pathtest_github_workflows.py
More file actions
1724 lines (1537 loc) · 71.6 KB
/
Copy pathtest_github_workflows.py
File metadata and controls
1724 lines (1537 loc) · 71.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Static checks for repository GitHub Actions workflows."""
from __future__ import annotations
import hashlib
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from tests.conftest import requires_bash
REPO_ROOT = Path(__file__).resolve().parent.parent
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
# Match both the dedicated-step form (` uses: x@sha`) and the
# inline shorthand (` - uses: x@sha`) used in catalog-assign.yml.
USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P<ref>\S+)", re.MULTILINE)
PINNED_SHA_RE = re.compile(r"@[0-9a-f]{40}$", re.IGNORECASE)
PUBLISH_WORKFLOW = WORKFLOWS_DIR / "publish-pypi.yml"
PUBLISH_VALIDATION_STEPS = (
"Verify tag format",
"Verify tag matches package version",
)
LINT_WORKFLOW = WORKFLOWS_DIR / "lint.yml"
FEATURE_ASSESS_WORKFLOW = WORKFLOWS_DIR / "feature-assess.md"
FEATURE_ASSESS_COMPILED_WORKFLOW = WORKFLOWS_DIR / "feature-assess.lock.yml"
FEATURE_ASSESS_LABELS = {
"feature-go",
"feature-needs-clarification",
"feature-kill",
"feature-invalid",
}
COMMUNITY_SUBMISSION_WORKFLOWS = (
(
"bundle",
"bundle-submission",
"bundles/catalog.community.json",
"docs/community/bundles.md",
"Modify only `bundles/catalog.community.json`",
),
(
"extension",
"extension-submission",
"extensions/catalog.community.json",
"docs/community/extensions.md",
"Do not modify any other files",
),
(
"preset",
"preset-submission",
"presets/catalog.community.json",
"docs/community/presets.md",
"Do not modify any other files",
),
)
REPOSITORY_OWNED_DRAFT_PR_EXEMPTION = (
"This repository-owned gh-aw maintenance workflow does not perform the contributor "
"open-PR count check or request confirmation. After successful validation and "
"allowed catalog/docs file updates, emit the configured draft `create_pull_request` "
"safe output regardless of the submitter's or filing account's open PR count."
)
def _publish_workflow_steps() -> dict[str, dict[str, object]]:
workflow = yaml.safe_load(PUBLISH_WORKFLOW.read_text(encoding="utf-8"))
return {step["name"]: step for step in workflow["jobs"]["build"]["steps"]}
def _run_publish_validation_step(
step_name: str, tag: str, working_directory: Path
) -> subprocess.CompletedProcess[str]:
step = _publish_workflow_steps()[step_name]
env = os.environ.copy()
env["TAG"] = tag
env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{env['PATH']}"
return subprocess.run(
["bash", "-euo", "pipefail", "-c", step["run"]],
cwd=working_directory,
env=env,
capture_output=True,
text=True,
check=False,
)
def _write_project_version(working_directory: Path, version: str) -> None:
(working_directory / "pyproject.toml").write_text(
f'[project]\nversion = "{version}"\n', encoding="utf-8"
)
def _create_pull_request_allowed_files(source_text: str) -> list[str]:
create_pr_match = re.search(
r"(?m)^ create-pull-request:\n(?P<body>(?:^ [^\n]*\n?)+)",
source_text,
)
assert create_pr_match is not None
allowed_files_match = re.search(
r"(?m)^ allowed-files:\n(?P<files>(?:^ - [^\n]+\n?)+)",
create_pr_match.group("body"),
)
assert allowed_files_match is not None
return [
line.strip().removeprefix("- ")
for line in allowed_files_match.group("files").splitlines()
if line.strip()
]
def _workflow_frontmatter(source_text: str) -> dict[str, object]:
_, frontmatter, _ = source_text.split("---", maxsplit=2)
return yaml.safe_load(frontmatter)
def _gh_aw_metadata(compiled_text: str) -> dict[str, object]:
metadata_prefix = "# gh-aw-metadata: "
first_line = compiled_text.splitlines()[0]
assert first_line.startswith(metadata_prefix)
return json.loads(first_line.removeprefix(metadata_prefix))
def _workflow_step(steps: list[dict[str, object]], name: str) -> dict[str, object]:
return next(step for step in steps if step.get("name") == name)
def _agentic_workflow(name: str) -> tuple[str, str, dict, dict]:
source_text = (WORKFLOWS_DIR / f"{name}.md").read_text(encoding="utf-8")
compiled_text = (WORKFLOWS_DIR / f"{name}.lock.yml").read_text(encoding="utf-8")
return (
source_text,
compiled_text,
_workflow_frontmatter(source_text),
yaml.safe_load(compiled_text),
)
def _safe_output_config(compiled: dict) -> dict:
step = _workflow_step(
compiled["jobs"]["safe_outputs"]["steps"], "Process Safe Outputs"
)
return json.loads(step["env"]["GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG"])
def _bundle_success_label_step() -> dict:
_, _, source, _ = _agentic_workflow("add-community-bundle")
return _workflow_step(
source["jobs"]["conclusion"]["pre-steps"],
"Mark bundle submission passed after PR creation",
)
def test_bundle_success_labels_run_after_successful_pr_publication():
_, _, _, compiled = _agentic_workflow("add-community-bundle")
step = _bundle_success_label_step()
conclusion = compiled["jobs"]["conclusion"]
assert "safe_outputs" in conclusion["needs"]
assert conclusion["permissions"]["issues"] == "write"
assert step["if"] == (
"needs.safe_outputs.result == 'success' && "
"needs.safe_outputs.outputs.created_pr_number != ''"
)
assert _workflow_step(conclusion["steps"], step["name"]) == step
assert compiled["jobs"]["safe_outputs"]["outputs"]["created_pr_number"] == (
"${{ steps.process_safe_outputs.outputs.created_pr_number }}"
)
assert compiled["jobs"]["agent"]["permissions"]["issues"] == "read"
def _run_bundle_success_labels(result, pr_number, labels, fail_api=""):
step = _bundle_success_label_step()
harness = r"""
const fs = require('node:fs');
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
const labels = new Set(input.labels);
const calls = [];
const context = {repo: {owner: 'test-owner', repo: 'test-repo'}, payload: {issue: {number: 22}}};
const record = (api, args) => {
calls.push({api, args});
if (api === input.fail_api) throw new Error(`API failure: ${api}`);
};
const github = {
rest: {issues: {
listLabelsOnIssue: 'listLabelsOnIssue',
removeLabel: async args => { record('removeLabel', args); labels.delete(args.name); },
addLabels: async args => { record('addLabels', args); args.labels.forEach(x => labels.add(x)); }
}},
paginate: async (api, args) => { record(api, args); return [...labels].map(name => ({name})); }
};
const needs = {safe_outputs: {result: input.result, outputs: {created_pr_number: input.pr_number}}};
const shouldRun = new Function('needs', `return ${input.condition}`)(needs);
(async () => {
let error = null;
try {
if (shouldRun) {
const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor;
await new AsyncFunction('github', 'context', input.script)(github, context);
}
} catch (e) { error = e.message; }
console.log(JSON.stringify({labels: [...labels].sort(), calls, error}));
})();
"""
completed = subprocess.run(
["node", "-e", harness],
input=json.dumps({
"condition": step["if"], "script": step["with"]["script"],
"result": result, "pr_number": pr_number, "labels": labels, "fail_api": fail_api,
}),
capture_output=True, text=True, check=True,
)
return json.loads(completed.stdout)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
@pytest.mark.parametrize("labels", [
["bundle-submission", "validation-failed"],
["bundle-submission", "validation-failed", "needs-info", "triaged"],
["bundle-submission", "validation-passed"],
])
def test_bundle_success_labels_correct_omitted_agent_updates(labels):
result = _run_bundle_success_labels("success", "37", labels)
assert result["error"] is None
assert result["labels"] == sorted(
(set(labels) - {"validation-failed", "needs-info"}) | {"validation-passed"}
)
assert result["calls"][-1]["api"] == "addLabels"
assert result["calls"][-1]["args"]["labels"] == ["validation-passed"]
for call in result["calls"]:
assert call["args"]["owner"] == "test-owner"
assert call["args"]["repo"] == "test-repo"
assert call["args"]["issue_number"] == 22
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
@pytest.mark.parametrize(("status", "pr_number"), [
("success", ""), ("failure", ""), ("failure", "37"),
("cancelled", "37"), ("skipped", ""),
])
def test_bundle_success_labels_do_not_run_without_successful_publication(status, pr_number):
labels = ["bundle-submission", "validation-failed"]
result = _run_bundle_success_labels(status, pr_number, labels)
assert result == {"labels": sorted(labels), "calls": [], "error": None}
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
@pytest.mark.parametrize("fail_api", ["listLabelsOnIssue", "removeLabel", "addLabels"])
def test_bundle_success_labels_surface_api_errors(fail_api):
result = _run_bundle_success_labels(
"success", "37", ["bundle-submission", "validation-failed"], fail_api
)
assert result["error"] == f"API failure: {fail_api}"
assert result["calls"][-1]["api"] == fail_api
assert "validation-passed" not in result["labels"]
@pytest.mark.parametrize("name", [
"add-community-bundle", "add-community-extension", "add-community-preset",
"bug-assess", "bug-fix", "bug-test", "feature-assess",
])
def test_agentic_workflow_labels_are_applied_not_suggested(name):
source_text, compiled_text, source, compiled = _agentic_workflow(name)
assert source["safe-outputs"]["add-labels"]["issue-intent"] is False
assert _safe_output_config(compiled)["add_labels"]["issue_intent"] is False
agent_config_step = _workflow_step(
compiled["jobs"]["agent"]["steps"], "Generate Safe Outputs Config"
)
agent_config = json.loads(agent_config_step["env"]["GH_AW_SAFE_OUTPUTS_CONFIG"])
assert agent_config["add_labels"]["issue_intent"] is False
responsibilities = " ".join(
source_text.split("## Label Responsibilities\n", 1)[1].split("\n## ", 1)[0].split()
)
assert "Applying the outcome labels is your responsibility" in responsibilities
assert (
"Use the `add_labels` safe output on source issue "
"#${{ github.event.issue.number }}"
) in responsibilities
assert "with plain strings in its `labels` array" in responsibilities
assert (
"Never emit label objects with `suggest: true` or suggestion-only output."
) in responsibilities
assert f"{{{{#runtime-import .github/workflows/{name}.md}}}}" in compiled_text
if name.startswith("add-community-"):
assert 'For a Passed outcome, emit `labels: ["validation-passed"]`' in responsibilities
assert 'for a Failed outcome, emit `labels: ["validation-failed"]`' in responsibilities
assert (
"this requirement does not turn environment blockers into submission failures."
) in responsibilities
def test_github_actions_are_pinned_to_full_commit_shas():
unpinned_refs = []
workflows = sorted(
list(WORKFLOWS_DIR.glob("*.yml")) + list(WORKFLOWS_DIR.glob("*.yaml"))
)
assert workflows
for workflow in workflows:
workflow_text = workflow.read_text(encoding="utf-8")
for match in USES_RE.finditer(workflow_text):
uses_ref = match.group("ref")
if uses_ref.startswith(("./", "../")):
continue
if PINNED_SHA_RE.search(uses_ref):
continue
unpinned_refs.append(f"{workflow.relative_to(REPO_ROOT)}: {uses_ref}")
assert unpinned_refs == []
def test_publish_tag_validation_uses_environment_variable():
steps = _publish_workflow_steps()
for step_name in PUBLISH_VALIDATION_STEPS:
step = steps[step_name]
assert step["env"]["TAG"] == "${{ inputs.tag }}"
assert "${{ inputs.tag }}" not in step["run"]
@requires_bash
def test_publish_tag_validation_accepts_valid_tag(tmp_path):
_write_project_version(tmp_path, "1.2.3")
for step_name in PUBLISH_VALIDATION_STEPS:
result = _run_publish_validation_step(step_name, "v1.2.3", tmp_path)
assert result.returncode == 0, result.stderr
@requires_bash
def test_publish_tag_validation_rejects_invalid_tag(tmp_path):
for invalid_tag in ("1.2.3", "v1.2", "v1.2.3-rc1"):
result = _run_publish_validation_step(
"Verify tag format", invalid_tag, tmp_path
)
assert result.returncode != 0
assert "is not a valid release tag" in result.stdout
injected_file = tmp_path / "interpolated"
injected_tag = f'v1.2.3"; touch "{injected_file}"; #'
result = _run_publish_validation_step("Verify tag format", injected_tag, tmp_path)
assert result.returncode != 0
assert not injected_file.exists()
@requires_bash
def test_publish_tag_validation_rejects_version_mismatch(tmp_path):
_write_project_version(tmp_path, "1.2.3")
result = _run_publish_validation_step(
"Verify tag matches package version", "v1.2.4", tmp_path
)
assert result.returncode != 0
assert "does not match pyproject.toml version" in result.stdout
def test_pinned_action_ref_accepts_uppercase_hex_sha():
assert PINNED_SHA_RE.search(
"actions/example@0123456789ABCDEF0123456789ABCDEF01234567"
)
def test_feature_assess_upgrade_preserves_positive_execution_path():
source_text = FEATURE_ASSESS_WORKFLOW.read_text(encoding="utf-8")
compiled_text = FEATURE_ASSESS_COMPILED_WORKFLOW.read_text(encoding="utf-8")
source = _workflow_frontmatter(source_text)
compiled = yaml.safe_load(compiled_text)
metadata = _gh_aw_metadata(compiled_text)
assert metadata["compiler_version"] == "v0.88.7"
assert metadata["engine_versions"] == {"copilot": "1.0.80"}
source_steps = source["steps"]
compiled_steps = compiled["jobs"]["agent"]["steps"]
expected_step_names = [
"Setup uv",
"Set up Python",
"Install Spec Kit CLI",
"Initialize Spec Kit and install the assess extension",
]
compiled_step_names = [step.get("name") for step in compiled_steps]
assert [
compiled_step_names.index(step_name) for step_name in expected_step_names
] == sorted(compiled_step_names.index(step_name) for step_name in expected_step_names)
for source_step in source_steps:
compiled_step = _workflow_step(compiled_steps, source_step["name"])
for field in ("continue-on-error", "uses", "with", "working-directory", "run"):
if field in source_step:
assert compiled_step[field] == source_step[field]
install_step = _workflow_step(compiled_steps, "Install Spec Kit CLI")
assert 'PIP_SUBCOMMAND=pip' in install_step["run"]
assert '"$UV_BIN" "$PIP_SUBCOMMAND" install --system .' in install_step["run"]
def test_feature_assess_upgrade_preserves_negative_guards():
source_text = FEATURE_ASSESS_WORKFLOW.read_text(encoding="utf-8")
compiled_text = FEATURE_ASSESS_COMPILED_WORKFLOW.read_text(encoding="utf-8")
source = _workflow_frontmatter(source_text)
compiled = yaml.safe_load(compiled_text)
assert (source.get("on") or source[True]) == {
"issues": {"types": ["labeled"], "names": ["feature-assess"]},
"skip-bots": ["github-actions", "copilot", "dependabot"],
}
assert (compiled.get("on") or compiled[True]) == {
"issues": {"types": ["labeled"]},
}
activation_condition = compiled["jobs"]["activation"]["if"]
pre_activation = compiled["jobs"]["pre_activation"]
expected_guard = (
"github.event_name != 'issues' || github.event.action != 'labeled' || "
"github.event.label.name == 'feature-assess'"
)
assert " ".join(pre_activation["if"].split()) == expected_guard
assert " ".join(activation_condition.split()) == (
f"needs.pre_activation.outputs.activated == 'true' && ({expected_guard})"
)
assert pre_activation["steps"][-1]["env"]["GH_AW_SKIP_BOTS"] == (
"github-actions,copilot-swe-agent,Copilot,copilot,"
"@app/copilot-swe-agent,dependabot"
)
agent = compiled["jobs"]["agent"]
assert agent["permissions"] == {"contents": "read", "issues": "read"}
assert compiled["jobs"]["safe_outputs"]["permissions"] == {
"issues": "write",
"pull-requests": "write",
}
safe_outputs_step = _workflow_step(
compiled["jobs"]["safe_outputs"]["steps"], "Process Safe Outputs"
)
safe_outputs = json.loads(
safe_outputs_step["env"]["GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG"]
)
assert safe_outputs["add_comment"] == {"max": 5}
assert safe_outputs["add_labels"]["max"] == 1
assert set(safe_outputs["add_labels"]["allowed"]) == FEATURE_ASSESS_LABELS
assert set(safe_outputs["remove_labels"]["allowed"]) == FEATURE_ASSESS_LABELS
assert not {
"create_issue",
"create_pull_request",
"push_to_pull_request",
} & safe_outputs.keys()
assert re.search(r"without applying any verdict\s+label", source_text)
assert "never stage,\n commit, or push" in source_text
unpinned_refs = [
match.group("ref")
for match in USES_RE.finditer(compiled_text)
if not match.group("ref").startswith(("./", "../"))
and not PINNED_SHA_RE.search(match.group("ref"))
]
assert unpinned_refs == []
def test_community_submission_automation_is_wired_to_allowed_files():
assignment = WORKFLOWS_DIR / "catalog-assign.yml"
assignment_text = assignment.read_text(encoding="utf-8")
for workflow, label, catalog_file, docs_file, instruction in (
COMMUNITY_SUBMISSION_WORKFLOWS
):
source = WORKFLOWS_DIR / f"add-community-{workflow}.md"
compiled = WORKFLOWS_DIR / f"add-community-{workflow}.lock.yml"
assert source.is_file()
assert compiled.is_file()
source_text = source.read_text(encoding="utf-8")
compiled_text = compiled.read_text(encoding="utf-8")
assert f"names: [{label}]" in source_text
assert catalog_file in source_text
assert docs_file in source_text
assert instruction in source_text
assert _create_pull_request_allowed_files(source_text) == [
catalog_file,
docs_file,
]
assert _safe_output_config(yaml.safe_load(compiled_text))[
"create_pull_request"
]["allowed_files"] == [catalog_file, docs_file]
assert label in assignment_text
def test_extension_submission_form_applies_only_automatic_intake_verdict():
forms_dir = REPO_ROOT / ".github" / "ISSUE_TEMPLATE"
extension_form = yaml.safe_load(
(forms_dir / "extension_submission.yml").read_text(encoding="utf-8")
)
assert extension_form["labels"] == ["triage-must-have"]
assert "extension-submission" not in extension_form["labels"]
def test_preset_submission_form_applies_only_automatic_intake_verdict():
forms_dir = REPO_ROOT / ".github" / "ISSUE_TEMPLATE"
preset_form = yaml.safe_load(
(forms_dir / "preset_submission.yml").read_text(encoding="utf-8")
)
assert preset_form["labels"] == ["triage-must-have"]
assert "preset-submission" not in preset_form["labels"]
def test_bundle_submission_form_applies_only_automatic_intake_verdict():
forms_dir = REPO_ROOT / ".github" / "ISSUE_TEMPLATE"
bundle_form = yaml.safe_load(
(forms_dir / "bundle_submission.yml").read_text(encoding="utf-8")
)
assert bundle_form["labels"] == ["triage-must-have"]
assert "bundle-submission" not in bundle_form["labels"]
def test_workflow_step_submission_form_applies_only_automatic_intake_verdict():
forms_dir = REPO_ROOT / ".github" / "ISSUE_TEMPLATE"
workflow_step_form = yaml.safe_load(
(forms_dir / "workflow_step_submission.yml").read_text(encoding="utf-8")
)
assert workflow_step_form["labels"] == ["triage-must-have"]
assert not {
"enhancement",
"needs-triage",
"workflow-step-submission",
"validation-passed",
"validation-failed",
} & set(workflow_step_form["labels"])
def test_workflow_step_submission_form_has_valid_complete_field_contract():
forms_dir = REPO_ROOT / ".github" / "ISSUE_TEMPLATE"
workflow_step_form = yaml.safe_load(
(forms_dir / "workflow_step_submission.yml").read_text(encoding="utf-8")
)
fields = [item for item in workflow_step_form["body"] if "id" in item]
field_ids = [field["id"] for field in fields]
assert len(field_ids) == len(set(field_ids))
assert all(re.fullmatch(r"[A-Za-z0-9_-]+", field_id) for field_id in field_ids)
assert set(field_ids) == {
"step-id",
"step-name",
"version",
"description",
"author",
"repository",
"download-url",
"step-yml-url",
"init-url",
"extra-files",
"file-sha256",
"license",
"speckit-compatibility",
"runtime-dependencies",
"step-type-count",
"step-types-provided",
"documentation",
"changelog",
"testing-details",
"attestations",
"additional-context",
"ai-disclosure",
}
required_ids = {
"step-id",
"step-name",
"version",
"description",
"author",
"repository",
"download-url",
"step-yml-url",
"init-url",
"extra-files",
"file-sha256",
"license",
"speckit-compatibility",
"runtime-dependencies",
"step-type-count",
"step-types-provided",
"documentation",
"testing-details",
"ai-disclosure",
}
assert {
field["id"]
for field in fields
if field.get("validations", {}).get("required") is True
} == required_ids
field_by_id = {field["id"]: field for field in fields}
assert field_by_id["step-type-count"][
"attributes"
]["options"] == ["1"]
assert all(
option["required"] is True
for option in field_by_id["attestations"]["attributes"]["options"]
)
bundle_form = yaml.safe_load(
(forms_dir / "bundle_submission.yml").read_text(encoding="utf-8")
)
bundle_fields = {
item["id"]: item for item in bundle_form["body"] if "id" in item
}
assert field_by_id["download-url"]["attributes"]["label"] == (
bundle_fields["download-url"]["attributes"]["label"]
)
download_description = field_by_id["download-url"]["attributes"]["description"]
assert "versioned" in download_description
assert "immutable" not in download_description
assert "immutable" not in yaml.safe_dump(workflow_step_form).lower()
extra_files_description = field_by_id["extra-files"]["attributes"]["description"]
assert "forward slashes" in extra_files_description
assert "relative and non-empty" in extra_files_description
assert "no empty, `.` or `..` segments" in extra_files_description
assert "case-insensitively alias `step.yml` or `__init__.py`" in (
extra_files_description
)
feature_form = yaml.safe_load(
(forms_dir / "feature_request.yml").read_text(encoding="utf-8")
)
feature_fields = {
item["id"]: item for item in feature_form["body"] if "id" in item
}
assert field_by_id["ai-disclosure"] == feature_fields["ai-disclosure"]
def test_workflow_step_submission_form_documents_intake_only_phase():
forms_dir = REPO_ROOT / ".github" / "ISSUE_TEMPLATE"
workflow_step_form = yaml.safe_load(
(forms_dir / "workflow_step_submission.yml").read_text(encoding="utf-8")
)
introduction = workflow_step_form["body"][0]["attributes"]["value"]
assert "This phase is intake-only" in introduction
assert "no validation workflow or draft pull request is triggered" in introduction
assert "update the community catalog through the normal reviewed pull request" in (
introduction
)
def test_other_issue_forms_do_not_apply_automatic_intake_verdict():
forms_dir = REPO_ROOT / ".github" / "ISSUE_TEMPLATE"
automatic_intake_forms = {
"bundle_submission.yml",
"extension_submission.yml",
"preset_submission.yml",
"workflow_step_submission.yml",
}
other_forms = sorted(
path
for path in forms_dir.glob("*.yml")
if path.name != "config.yml" and path.name not in automatic_intake_forms
)
assert [path.name for path in other_forms] == [
"agent_request.yml",
"bug_report.yml",
"feature_request.yml",
]
for form_path in other_forms:
form = yaml.safe_load(form_path.read_text(encoding="utf-8"))
assert "triage-must-have" not in form["labels"]
@pytest.mark.parametrize("kind", [item[0] for item in COMMUNITY_SUBMISSION_WORKFLOWS])
def test_community_upgrade_uses_established_runtime_defaults(kind):
_, compiled_text, source, compiled = _agentic_workflow(f"add-community-{kind}")
metadata = _gh_aw_metadata(compiled_text)
assert metadata["compiler_version"] == "v0.88.7"
assert metadata["engine_versions"] == {"copilot": "1.0.80"}
assert metadata["strict"] is True
assert set(source["engine"]) == {"id", "args"}
assert source["engine"]["id"] == "copilot"
info = _workflow_step(
compiled["jobs"]["activation"]["steps"], "Generate agentic run info"
)["env"]
assert info["GH_AW_INFO_MODEL"] == (
"${{ vars.GH_AW_MODEL_AGENT_COPILOT || "
"vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}"
)
assert info["GH_AW_INFO_AWF_VERSION"] == "v0.28.14"
manifest_prefix = "# gh-aw-manifest: "
manifest = json.loads(compiled_text.splitlines()[1].removeprefix(manifest_prefix))
assert {container["image"] for container in manifest["containers"]} == {
"ghcr.io/github/gh-aw-firewall/agent:0.28.14",
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14",
"ghcr.io/github/gh-aw-firewall/squid:0.28.14",
"ghcr.io/github/gh-aw-mcpg:v0.4.18",
"ghcr.io/github/gh-aw-node",
"ghcr.io/github/github-mcp-server:v1.11.0",
}
for container in manifest["containers"]:
assert re.fullmatch(r"sha256:[0-9a-f]{64}", container["digest"])
assert container["pinned_image"] == (
f"{container['image']}@{container['digest']}"
)
refs = {match.group("ref") for match in USES_RE.finditer(compiled_text)}
assert refs
assert all(PINNED_SHA_RE.search(ref) for ref in refs)
assert {ref for ref in refs if ref.startswith("github/gh-aw-actions/")} == {
"github/gh-aw-actions/setup@5e508589e03a7757a7e05b26e834292f5445bfb6"
}
@pytest.mark.parametrize("kind", [item[0] for item in COMMUNITY_SUBMISSION_WORKFLOWS])
def test_community_upgrade_preserves_activation_and_permission_guards(kind):
_, _, source, compiled = _agentic_workflow(f"add-community-{kind}")
label = f"{kind}-submission"
assert (source.get("on") or source[True]) == {
"issues": {"types": ["labeled"], "names": [label]},
"skip-bots": ["github-actions", "copilot", "dependabot"],
}
assert (compiled.get("on") or compiled[True]) == {
"issues": {"types": ["labeled"]}
}
guard = (
"github.event_name != 'issues' || github.event.action != 'labeled' || "
f"github.event.label.name == '{label}'"
)
pre_activation = compiled["jobs"]["pre_activation"]
assert " ".join(pre_activation["if"].split()) == guard
assert " ".join(compiled["jobs"]["activation"]["if"].split()) == (
f"needs.pre_activation.outputs.activated == 'true' && ({guard})"
)
assert pre_activation["outputs"]["activated"] == (
"${{ steps.check_membership.outputs.is_team_member == 'true' && "
"steps.check_skip_bots.outputs.skip_bots_ok == 'true' }}"
)
assert _workflow_step(
pre_activation["steps"], "Check team membership for workflow"
)["env"]["GH_AW_REQUIRED_ROLES"] == "admin,maintainer,write"
assert _workflow_step(pre_activation["steps"], "Check skip-bots")["env"][
"GH_AW_SKIP_BOTS"
] == "github-actions,copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent,dependabot"
agent = compiled["jobs"]["agent"]
assert agent["needs"] == "activation"
assert agent["if"] == "needs.activation.outputs.daily_ai_credits_exceeded != 'true'"
assert compiled["permissions"] == {}
assert source["permissions"] == agent["permissions"] == {
"contents": "read", "issues": "read"
}
assert source["tools"]["github"] == {
"toolsets": ["issues", "repos"], "min-integrity": "none"
}
assert source["checkout"] == {"fetch-depth": 0}
assert _workflow_step(agent["steps"], "Checkout repository")["with"] == {
"persist-credentials": False, "fetch-depth": 0
}
output_permissions = {
"contents": "write", "issues": "write", "pull-requests": "write"
}
assert compiled["jobs"]["safe_outputs"]["permissions"] == output_permissions
assert compiled["jobs"]["conclusion"]["permissions"] == {
**output_permissions, "actions": "read"
}
@pytest.mark.parametrize(
"kind,label,catalog_file,docs_file,instruction", COMMUNITY_SUBMISSION_WORKFLOWS
)
def test_community_upgrade_preserves_scoped_draft_pr_contract(
kind, label, catalog_file, docs_file, instruction
):
source_text, compiled_text, source, compiled = _agentic_workflow(
f"add-community-{kind}"
)
assert instruction in source_text
assert REPOSITORY_OWNED_DRAFT_PR_EXEMPTION in " ".join(source_text.split())
assert (
f"{{{{#runtime-import .github/workflows/add-community-{kind}.md}}}}"
in compiled_text
)
outputs = _safe_output_config(compiled)
expected_outputs = {
"add_comment", "add_labels", "create_pull_request", "noop",
"create_report_incomplete_issue", "missing_data", "missing_tool",
"report_incomplete", "remove_labels",
}
expected_source_outputs = {
"add-comment", "add-labels", "create-pull-request", "noop",
"threat-detection", "remove-labels",
}
removable_labels = (
["validation-passed", "validation-failed", "needs-info"]
if kind == "bundle" else ["validation-passed", "validation-failed"]
)
assert outputs["remove_labels"]["allowed"] == source["safe-outputs"][
"remove-labels"
]["allowed"] == removable_labels
assert set(outputs) == expected_outputs
assert set(source["safe-outputs"]) == expected_source_outputs
assert outputs["add_comment"] == source["safe-outputs"]["add-comment"] == {"max": 2}
expected_labels = {
"allowed": [label, "validation-passed", "validation-failed", "needs-info"],
"max": 3,
}
assert outputs["add_labels"] == {**expected_labels, "issue_intent": False}
assert source["safe-outputs"]["add-labels"] == {
**expected_labels, "issue-intent": False
}
assert source["safe-outputs"]["noop"] == {"report-as-issue": False}
assert outputs["noop"] == {"max": 1, "report-as-issue": "false"}
assert source["safe-outputs"]["create-pull-request"] == {
"title-prefix": f"[{kind}] ",
"labels": [label, "automated"],
"draft": True,
"max": 1,
"allowed-files": [catalog_file, docs_file],
"protected-files": {
"policy": "blocked", "exclude": ["README.md", "CHANGELOG.md"]
},
}
create_pr = outputs["create_pull_request"]
assert create_pr["allowed_files"] == [catalog_file, docs_file]
assert create_pr["draft"] is True
assert create_pr["title_prefix"] == f"[{kind}] "
assert create_pr["labels"] == [label, "automated"]
assert create_pr["max"] == 1
assert create_pr["max_patch_files"] == 100
assert create_pr["max_patch_size"] == 4096
assert create_pr["protected_files_policy"] == "blocked"
assert create_pr["protect_top_level_dot_folders"] is True
assert not create_pr.get("protected_dot_folder_excludes")
assert "AGENTS.md" in create_pr["protected_files"]
assert not {"README.md", "CHANGELOG.md", "CLAUDE.md", "GEMINI.md"} & set(
create_pr["protected_files"]
)
# Full clauses from the catalog download-URL checks (issue #4185). Assert the
# complete sentences so independent keywords cannot drift apart.
_CATALOG_DOWNLOAD_URL_CLAUSES = (
(
"The download URL MUST belong to the submitted repository\n"
" (`https://github.com/<owner>/<repo>/...` with the same `<owner>/<repo>` as\n"
" the Repository URL). Reject URLs for any other GitHub repository."
),
(
"If the download URL path contains `releases/latest/`, reject with an\n"
" explanation — this URL is floating and not acceptable. Mark this pinning\n"
" check failed and skip the HTTP request for this URL, then continue the\n"
" remaining validations."
),
(
"The `<tag>` segment in the URL MUST correspond to the submitted version.\n"
" Accept `vX.Y.Z`, `X.Y.Z`, and scoped tags whose version suffix matches\n"
" (for example `aide-v1.0.0` for version `1.0.0`). Reject a tag whose\n"
" embedded semver does not equal the submitted version."
),
(
"Only after all pinning checks pass, fetch the download URL and perform the\n"
" remaining artifact checks:\n"
" - Verify the URL returns HTTP 200.\n"
" - If `sha256` is included, verify it matches the downloaded archive. Requiring\n"
" `sha256` on every catalog entry is follow-up work and MUST NOT fail this\n"
" check when the field is absent."
),
)
def test_community_submission_workflows_require_tag_pinned_download_urls():
"""Catalog agents must reject floating releases/latest URLs (issue #4185)."""
for workflow, *_ in COMMUNITY_SUBMISSION_WORKFLOWS:
source_text = (WORKFLOWS_DIR / f"add-community-{workflow}.md").read_text(
encoding="utf-8"
)
assert "should follow the pattern" not in source_text.lower()
for clause in _CATALOG_DOWNLOAD_URL_CLAUSES:
assert clause in source_text, f"missing clause in {workflow}: {clause!r}"
if workflow == "bundle":
assert (
"`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`."
in source_text
)
assert "archive/refs/tags/" not in source_text
else:
assert (
"`https://github.com/<owner>/<repo>/archive/refs/tags/<tag>.zip`"
in source_text
)
assert (
"`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`"
in source_text
)
@pytest.mark.parametrize("kind", [item[0] for item in COMMUNITY_SUBMISSION_WORKFLOWS])
def test_community_checksum_instructions_preserve_submitted_digest(kind):
source_text, _, _, _ = _agentic_workflow(f"add-community-{kind}")
parsing = source_text.split("## Step 1", 1)[1].split("## Step 2", 1)[0]
prose = " ".join(parsing.split())
form = yaml.safe_load(
(REPO_ROOT / ".github" / "ISSUE_TEMPLATE" / f"{kind}_submission.yml").read_text(
encoding="utf-8"
)
)
form_ids = {field["id"] for field in form["body"] if "id" in field}
documented_ids = set(re.findall(r"^\| [^|\n]+ \| `([^`]+)` \|", parsing, re.MULTILINE))
assert documented_ids <= form_ids
assert "not a dedicated issue-form input" in prose
assert "manually appended `### SHA-256` heading" in prose
assert "### SHA-256 (sha256)" in prose
assert "from the submitted issue, not release metadata or the computed digest" in prose
assert "exactly 64 hexadecimal characters" in prose
if kind == "preset":
assert "Proposed Catalog Entry" not in parsing
else:
assert "the `sha256` field in the Proposed Catalog Entry" in prose
assert "If both sources supply a checksum, they must agree" in prose
comparison = source_text.split("Compute SHA-256 only after", 1)[1].split(
"A blocked or failed download", 1
)[0]
comparison_prose = " ".join(comparison.split())
assert "EXPECTED_SHA256 /tmp/gh-aw/community-archive.zip" in source_text
assert "the validated `submitted_sha256`" in comparison_prose
assert "Never replace a mismatching submitted checksum" in comparison_prose
assert "A `FAILED` checksum comparison is a Failed outcome" in comparison_prose
assert (
"remove `validation-passed`, add `validation-failed`, and stop "
"without catalog/docs edits or a PR."
) in comparison_prose
assert "Require exit code 0 and an `OK` result" in comparison_prose
assert "If no checksum was submitted, skip the comparison" in comparison_prose
assert "sha256sum /tmp/gh-aw/community-archive.zip" in comparison
assert "record its digest as `actual_sha256`" in comparison_prose
@pytest.mark.parametrize("kind", [item[0] for item in COMMUNITY_SUBMISSION_WORKFLOWS])
def test_community_catalog_records_computed_checksum_only_after_validation(kind):
source_text, _, _, _ = _agentic_workflow(f"add-community-{kind}")
catalog = source_text.split("## Step 4", 1)[1].split("## Step 5", 1)[0]
prose = " ".join(catalog.split())
assert '"sha256": "<actual_sha256>"' in catalog
assert (
"For both new entries and updates, only after every required validation "
"passes, set `sha256` to `actual_sha256` from the downloaded archive."
) in prose
assert "Do this even when no checksum was submitted." in prose
assert "Replace any previous catalog digest; do not reuse a digest from an older archive." in prose
assert "A submitted mismatch must fail validation before this step" in prose
@pytest.mark.skipif(shutil.which("sha256sum") is None, reason="sha256sum not available")
@pytest.mark.parametrize("kind", [item[0] for item in COMMUNITY_SUBMISSION_WORKFLOWS])
@pytest.mark.parametrize("case", ["matching", "mismatch", "malformed"])
def test_community_checksum_command_rejects_invalid_digest(kind, case, tmp_path):
source_text, _, _, _ = _agentic_workflow(f"add-community-{kind}")
command = re.search(r"```bash\n(sha256sum --check[^\n]+)\n```", source_text)
assert command is not None
args = shlex.split(command[1])
assert args == [
"sha256sum", "--check", "--strict", "/tmp/gh-aw/community-archive.sha256",
]
manifest = re.search(
r"```text\n(EXPECTED_SHA256 /tmp/gh-aw/community-archive.zip)\n```", source_text
)
assert manifest is not None
archive = tmp_path / "community-archive.zip"
archive.write_bytes(b"community archive fixture\n")
expected = {
"matching": hashlib.sha256(archive.read_bytes()).hexdigest(),
"mismatch": "0" * 64,
"malformed": "not-a-sha256",
}[case]
checksum_file = tmp_path / "community-archive.sha256"
checksum_file.write_text(
manifest[1].replace("EXPECTED_SHA256", expected).replace(
"/tmp/gh-aw/community-archive.zip", archive.name
) + "\n",
encoding="utf-8", newline="\n",
)
executable = shutil.which("sha256sum")
assert executable is not None
result = subprocess.run(
[executable, *args[1:-1], checksum_file.name],
cwd=tmp_path, capture_output=True, text=True, check=False,
)
if case == "matching":
assert result.returncode == 0, result.stderr
assert f"{archive.name}: OK" in result.stdout
else: