OXYGENHUB

Environments: environment "prod" on a job f4e51e63582d

flow-core parses a job's environment (1 to 32 of a-z 0-9 -), hashed as job
field 12. The hub records what each environment runs and names it in the
job's identity tokens only once the job is approved. Spec 5.3.3; test.

Refs #59
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 04:30 UTC
Signature
✔ signed by Claude Code
Hash
f4e51e63582d7178ecf5dbcfde1b925a3ef48fe8649810cb68a2a71c46a11c0e
Depends on
46fe2c815cc3 a083cc802892 a67b97e9a717 d60aac6c3f8a e0918b887850 e20897216221 e2c125610f43 e576e08617dc
Depended on by
28cc603f737d cd03b9844516 2be1fe4ad345
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
7 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/flow-core/src/canonical.rs +4 −0
…
110 unchanged lines
111 111
    if let Some(a) = &j.approval {
112 112
        fields.push((11, Value::Array(vec![text(&a.by), Value::Bool(a.others_only), Value::Uint(a.expires_s)])));
113 113
    }
114 +
    // The environment it deploys to; absent without one.
115 +
    if let Some(e) = &j.environment {
116 +
        fields.push((12, text(e)));
117 +
    }
114 118
    if !j.inputs.is_empty() {
115 119
        fields.push((
116 120
            10,
…
47 unchanged lines
modified crates/flow-core/src/pipeline.rs +17 −0
…
180 unchanged lines
181 181
    /// "maintain"; others-only #true; expires "24h" }`.
182 182
    #[serde(default, skip_serializing_if = "Option::is_none")]
183 183
    pub approval: Option<Approval>,
184 +
    /// The environment the job deploys to (oxygenflow#59), e.g. `prod`: the
185 +
    /// hub records what each environment runs, and the job's identity token
186 +
    /// names it once the job has been approved.
187 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
188 +
    pub environment: Option<String>,
184 189
    /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub
185 190
    /// supplies each by name, the runner mounts it as a file.
186 191
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
…
1240 unchanged lines
1427 1432
        outputs: vec![],
1428 1433
        inputs: vec![],
1429 1434
        approval: None,
1435 +
        environment: None,
1430 1436
        runs_on: vec![],
1431 1437
        timeout_s: None,
1432 1438
        on: None,
…
73 unchanged lines
1506 1512
            }
1507 1513
            "input" => parse_input(ctx, c, &mut job),
1508 1514
            "approval" => job.approval = parse_approval(ctx, c),
1515 +
            "environment" => {
1516 +
                ctx.no_children(c);
1517 +
                if let Some(e) = ctx.one_string(c) {
1518 +
                    let ok = !e.is_empty() && e.len() <= 32 && e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
1519 +
                    if ok {
1520 +
                        job.environment = Some(e);
1521 +
                    } else {
1522 +
                        ctx.err(c.span(), format!("environment {e:?}: 1 to 32 of a-z 0-9 -, as \"prod\""));
1523 +
                    }
1524 +
                }
1525 +
            }
1509 1526
            "secret" => parse_secret(ctx, c, &mut job),
1510 1527
            "identity" => parse_identity(ctx, c, &mut job),
1511 1528
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
…
32 unchanged lines
modified crates/flow-core/src/tests.rs +9 −0
…
692 unchanged lines
693 693
    has("    approval { expires \"800h\"; }\n", "from 1m to 720h");
694 694
    has("    approval { when \"later\"; }\n", "unknown approval setting");
695 695
}
696 +
697 +
#[test]
698 +
fn a_job_names_the_environment_it_deploys_to() {
699 +
    let job = |e: &str| doc(&format!("job \"apply\" {{\n    sandbox \"alpine\"\n{e}    exec \"apply\"\n}}\n"));
700 +
    let p = Pipeline::parse(&job("    environment \"prod\"\n")).unwrap();
701 +
    assert_eq!(p.jobs["apply"].environment.as_deref(), Some("prod"));
702 +
    assert_ne!(p.hash(), Pipeline::parse(&job("")).unwrap().hash(), "part of the job");
703 +
    assert!(problems(&job("    environment \"Prod env\"\n")).iter().any(|(_, m)| m.contains("1 to 32 of a-z 0-9 -")));
704 +
}
modified crates/flow/src/outputs.rs +1 −0
…
225 unchanged lines
226 226
            outputs: vec![],
227 227
            inputs: vec![],
228 228
            approval: None,
229 +
            environment: None,
229 230
            runs_on: vec![],
230 231
            timeout_s: None,
231 232
            on: None,
…
84 unchanged lines
modified docs/spec.md +13 −0
…
347 unchanged lines
348 348
bookmark cancels the run as before, so a stale plan is never applied. The
349 349
approval is part of the job's hash (field 11).
350 350
351 +
### 5.3.3 Environments (oxygenflow#59)
352 +
353 +
`environment "prod"` on a job says where it deploys (1 to 32 of `a-z`,
354 +
`0-9`, `-`; part of the job's hash, field 12). The hub keeps what each
355 +
environment of a repository runs, from the jobs that passed for it (state,
356 +
run, when, who approved), on the repository's Deployments page. The job's
357 +
identity tokens carry an `environment` claim only when the job was
358 +
approved (§5.3.2): a job with no approval block, or one not approved, gets
359 +
tokens without it. So a cloud's trust condition can require
360 +
`environment == "prod"`, and nothing unapproved deploys there, even from
361 +
the deploy bookmark. The approval block is the environment's protection
362 +
rule for now.
363 +
351 364
### 5.4 The Build Cache (oxygenflow#28)
352 365
353 366
An `ephemeral://disk` mount may name a `cache`, and the files that key it:
…
351 unchanged lines