-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathci_push.rs
More file actions
239 lines (223 loc) · 9.4 KB
/
Copy pathci_push.rs
File metadata and controls
239 lines (223 loc) · 9.4 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
//! CI-push execution-context contributor (Stage 3 of the exec-context
//! contributor build-out — see plan.md).
//!
//! Stages "since last green build on this branch" diff context for
//! non-PR push builds. Activates only when
//! `execution-context.ci-push.enabled: true` (opt-in, default OFF) —
//! the contributor does ADO REST + git fetch deepening work that
//! adds startup latency, so most agents shouldn't pay for it.
//!
//! Runtime gate: `and(succeeded(), or(eq(Build.Reason, 'IndividualCI'),
//! eq(Build.Reason, 'BatchedCI')))`. Skips PRs, scheduled runs, and
//! resource triggers at zero cost.
//!
//! ## Artefacts (staged by the bundle on success)
//!
//! - `aw-context/ci-push/current-sha` — `Build.SourceVersion`
//! - `aw-context/ci-push/previous-sha` — SHA of the last
//! successful build of this pipeline on this branch (resolved via
//! `shared/build.ts::listLastSuccessfulBuildOnBranch`)
//! - `aw-context/ci-push/base.sha` — `git merge-base`
//! between previous and current (usually `previous` itself; differs
//! if intervening rebases or non-linear history are involved)
//! - `aw-context/ci-push/commits.txt` — `git log previous..current --oneline`
//! - `aw-context/ci-push/changed-files.txt` — `git diff --name-status previous..current`
//! - `aw-context/ci-push/error.txt` — present only on failure
//!
//! ## Trust boundary
//!
//! Bearer required for both:
//! - the ADO Build REST API lookup ("last successful build")
//! - the git fetch deepening (to reach the previous SHA if the
//! workspace's shallow clone doesn't already include it)
//!
//! `SYSTEM_ACCESSTOKEN` is mapped only into this step's `env:` block;
//! never to the agent step's env. Same posture as the PR contributor.
//!
//! ## Failure modes
//!
//! - **No previous successful build** — first ever push for this
//! branch, or all previous builds failed, or last green build was
//! pruned by ADO retention. The bundle stages `error.txt` and
//! appends a failure fragment telling the agent NOT to claim
//! "diff is empty, ship it" when the diff couldn't be resolved.
//! - **Depth-budget exhausted** — `previous` SHA is older than the
//! deepening budget can reach. Same failure fragment.
//! - **REST failure** — Build API returned an error. Same.
use crate::compile::extensions::CompileContext;
use crate::compile::ado_bundle::{Bundle, TokenSource, apply_bundle_auth};
use crate::compile::extensions::ado_script::EXEC_CONTEXT_CI_PUSH_PATH;
use crate::compile::ir::condition::{Condition, Expr};
use crate::compile::ir::step::{BashStep, Step};
use crate::compile::types::CiPushContextConfig;
use super::contributor::{ContextContributor, succeeded_and};
/// CI-push-context contributor.
pub(super) struct CiPushContextContributor {
config: CiPushContextConfig,
}
impl CiPushContextContributor {
pub(super) fn new(config: CiPushContextConfig) -> Self {
Self { config }
}
}
impl ContextContributor for CiPushContextContributor {
fn name(&self) -> &str {
"ci-push"
}
fn should_activate(&self, _ctx: &CompileContext) -> bool {
// No trigger predicate — ci-push activation is purely
// config-driven (opt-in). Runtime gate (eq(Build.Reason,
// IndividualCI/BatchedCI)) means the step is a no-op on
// non-CI builds even when activated.
//
// MAINTENANCE: this MUST stay in lock-step with
// `super::ci_push_contributor_will_activate`.
self.config.is_enabled()
}
fn prepare_step_typed(&self, ctx: &CompileContext) -> anyhow::Result<Option<Step>> {
// Defensive: mirror the manual.rs pattern — `declarations()`
// already gates on `should_activate`, but this guard catches
// direct callers (tests / future tooling). Returning `Ok(None)`
// ensures no live step (with an active bearer) is emitted
// when the contributor is inactive.
if !self.should_activate(ctx) {
return Ok(None);
}
let script = format!("set -euo pipefail\nnode '{EXEC_CONTEXT_CI_PUSH_PATH}'\n");
// ADO auto-injects the predefined System.*/Build.* context variables
// into the step env, so the bundle reads them directly; only the
// non-auto-injected SYSTEM_ACCESSTOKEN bearer is projected here.
let step = apply_bundle_auth(
BashStep::new(
"Stage ci-push execution context (aw-context/ci-push/*)",
script,
)
.with_condition(succeeded_and(Condition::Or(vec![
Condition::Eq(
Expr::Variable("Build.Reason".to_string()),
Expr::Literal("IndividualCI".to_string()),
),
Condition::Eq(
Expr::Variable("Build.Reason".to_string()),
Expr::Literal("BatchedCI".to_string()),
),
]))),
Bundle::ExecContextCiPush,
TokenSource::SystemAccessToken,
);
Ok(Some(Step::Bash(step)))
}
fn bash_commands(&self) -> Vec<String> {
// Same seven read-only git commands as the PR contributor —
// the agent runs `git diff $BASE..$HEAD` and friends to
// inspect the staged commit range.
vec![
"git".to_string(),
"git diff".to_string(),
"git log".to_string(),
"git show".to_string(),
"git status".to_string(),
"git rev-parse".to_string(),
"git symbolic-ref".to_string(),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compile::ir::env::EnvValue;
use crate::compile::extensions::CompileContext;
use crate::compile::types::FrontMatter;
fn parse_fm(src: &str) -> FrontMatter {
let (fm, _) = crate::compile::common::parse_markdown(src).unwrap();
fm
}
fn minimal_fm() -> FrontMatter {
parse_fm("---\nname: test\ndescription: test\n---\n")
}
#[test]
fn defaults_to_disabled() {
let fm = minimal_fm();
let c = CiPushContextContributor::new(CiPushContextConfig::default());
let ctx = CompileContext::for_test(&fm);
assert!(!c.should_activate(&ctx), "ci-push must default to OFF");
}
#[test]
fn activates_when_explicitly_enabled() {
let fm = minimal_fm();
let c = CiPushContextContributor::new(CiPushContextConfig {
enabled: Some(true),
});
let ctx = CompileContext::for_test(&fm);
assert!(c.should_activate(&ctx));
}
#[test]
fn prepare_step_emits_or_individualci_batchedci_condition() {
let fm = minimal_fm();
let c = CiPushContextContributor::new(CiPushContextConfig {
enabled: Some(true),
});
let ctx = CompileContext::for_test(&fm);
let step = c.prepare_step_typed(&ctx).unwrap().unwrap();
let bash = match &step {
Step::Bash(b) => b,
other => panic!("expected Bash, got {other:?}"),
};
// Condition: and(succeeded(), or(eq(Build.Reason, IndividualCI),
// eq(Build.Reason, BatchedCI)))
match &bash.condition {
Some(Condition::And(parts)) => {
assert!(matches!(parts.as_slice(), [Condition::Succeeded, _]));
let Condition::Or(clauses) = &parts[1] else {
panic!("expected Or trigger condition, got {:?}", parts[1]);
};
assert_eq!(clauses.len(), 2);
let reasons: Vec<&str> = clauses
.iter()
.map(|c| match c {
Condition::Eq(Expr::Variable(_), Expr::Literal(l)) => l.as_str(),
_ => panic!("expected Eq clause, got {c:?}"),
})
.collect();
assert!(reasons.contains(&"IndividualCI"));
assert!(reasons.contains(&"BatchedCI"));
}
other => panic!("expected succeeded And condition, got {other:?}"),
}
// Trust boundary: bearer present, projected as a secret via the
// bundle-auth applier (not an AdoMacro).
assert!(matches!(
bash.env.get("SYSTEM_ACCESSTOKEN"),
Some(EnvValue::Secret(v)) if v == "System.AccessToken"
));
// All predefined System.*/Build.* context vars are auto-injected by
// ADO, so the step must not re-project them.
for stripped in [
"SYSTEM_COLLECTIONURI",
"SYSTEM_TEAMPROJECT",
"SYSTEM_DEFINITIONID",
"BUILD_BUILDID",
"BUILD_SOURCESDIRECTORY",
"BUILD_SOURCEVERSION",
"BUILD_SOURCEBRANCH",
] {
assert!(
bash.env.get(stripped).is_none(),
"{stripped} is auto-injected and must not be re-projected"
);
}
}
#[test]
fn bash_commands_includes_read_only_git_set() {
let c = CiPushContextContributor::new(CiPushContextConfig {
enabled: Some(true),
});
let cmds = c.bash_commands();
assert!(cmds.contains(&"git diff".to_string()));
assert!(cmds.contains(&"git log".to_string()));
assert!(cmds.contains(&"git show".to_string()));
assert!(cmds.contains(&"git status".to_string()));
assert!(cmds.contains(&"git rev-parse".to_string()));
assert!(cmds.contains(&"git symbolic-ref".to_string()));
}
}